@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.
Files changed (94) 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/http/create-app.test.ts +340 -0
  46. package/src/http/create-app.ts +196 -0
  47. package/src/http/openapi.ts +111 -0
  48. package/src/process/slot.ts +18 -0
  49. package/src/project/config.test.ts +140 -0
  50. package/src/project/config.ts +413 -0
  51. package/src/project/load.test.ts +60 -0
  52. package/src/project/load.ts +158 -0
  53. package/src/project/reload-graph.test.ts +52 -0
  54. package/src/project/reload-graph.ts +175 -0
  55. package/src/repository/define.ts +123 -0
  56. package/src/repository/generate.test.ts +58 -0
  57. package/src/repository/generate.ts +117 -0
  58. package/src/repository/query.test.ts +99 -0
  59. package/src/repository/query.ts +287 -0
  60. package/src/repository/runtime.test.ts +154 -0
  61. package/src/repository/runtime.ts +434 -0
  62. package/src/repository/sql.test.ts +78 -0
  63. package/src/repository/sql.ts +282 -0
  64. package/src/route/define.test.ts +65 -0
  65. package/src/route/define.ts +307 -0
  66. package/src/route/middleware.test.ts +63 -0
  67. package/src/route/middleware.ts +144 -0
  68. package/src/seed/apply.test.ts +65 -0
  69. package/src/seed/apply.ts +11 -0
  70. package/src/seed/define.test.ts +51 -0
  71. package/src/seed/define.ts +55 -0
  72. package/src/seed/load.test.ts +54 -0
  73. package/src/seed/load.ts +67 -0
  74. package/src/seed/report.test.ts +16 -0
  75. package/src/seed/report.ts +15 -0
  76. package/src/self-test/checks.ts +88 -0
  77. package/src/self-test/run.test.ts +128 -0
  78. package/src/self-test/run.ts +105 -0
  79. package/src/server/identity.test.ts +57 -0
  80. package/src/server/identity.ts +116 -0
  81. package/src/server/index.test.ts +15 -0
  82. package/src/server/index.ts +169 -0
  83. package/src/server/swap.test.ts +89 -0
  84. package/src/server/swap.ts +29 -0
  85. package/src/typeid/id.test.ts +46 -0
  86. package/src/typeid/id.ts +136 -0
  87. package/src/typeid/owned.test.ts +73 -0
  88. package/src/typeid/owned.ts +193 -0
  89. package/src/vault/client.test.ts +122 -0
  90. package/src/vault/client.ts +191 -0
  91. package/src/versioning/define.test.ts +79 -0
  92. package/src/versioning/define.ts +173 -0
  93. package/src/versioning/select.test.ts +79 -0
  94. package/src/versioning/select.ts +62 -0
@@ -0,0 +1,136 @@
1
+ import { fromString, getSuffix, getType, typeid } from "typeid-js";
2
+
3
+ const typePrefix = /^[a-z](?:[a-z_]{0,61}[a-z])?$/;
4
+ const objectSegment = /^(?:v[0-9]+|[a-z](?:[a-z_]{0,61}[a-z])?)$/;
5
+
6
+ export type ObjectRowMeta = {
7
+ _id: string;
8
+ _object: string;
9
+ _tenant: string;
10
+ _archived: false;
11
+ _createdAt: Date;
12
+ _updatedAt: Date;
13
+ };
14
+
15
+ export type CreateObjectRowOptions = {
16
+ tenant: string;
17
+ idPrefix?: string;
18
+ };
19
+
20
+ export type ParsedTypeId = {
21
+ prefix: string;
22
+ suffix: string;
23
+ };
24
+
25
+ export class TypeIdError extends Error {
26
+ constructor(message: string) {
27
+ super(message);
28
+ this.name = "TypeIdError";
29
+ }
30
+ }
31
+
32
+ export function objectTypeFromRoute(version: number, routePath: string) {
33
+ const segments = routePath.split("/").filter(Boolean);
34
+ if (segments.length === 0) {
35
+ throw new TypeIdError(
36
+ `route path must include at least one segment: ${routePath}`,
37
+ );
38
+ }
39
+ if (!Number.isInteger(version) || version < 1) {
40
+ throw new TypeIdError(`invalid API version: ${version}`);
41
+ }
42
+ for (const segment of segments) {
43
+ if (!typePrefix.test(segment)) {
44
+ throw new TypeIdError(
45
+ `invalid route segment "${segment}" in ${routePath}`,
46
+ );
47
+ }
48
+ }
49
+ return [`v${version}`, ...segments].join(".");
50
+ }
51
+
52
+ export function idPrefixFromObjectType(objectType: string) {
53
+ assertObjectType(objectType);
54
+ const prefix = objectType.split(".").at(-1);
55
+ if (!(prefix && typePrefix.test(prefix))) {
56
+ throw new TypeIdError(
57
+ `cannot derive type prefix from object type: ${objectType}`,
58
+ );
59
+ }
60
+ return prefix;
61
+ }
62
+
63
+ export function assertObjectType(objectType: string) {
64
+ const segments = objectType.split(".");
65
+ if (segments.length < 2) {
66
+ throw new TypeIdError(
67
+ `object type must be route-derived (e.g. v2.core.users), got: ${objectType}`,
68
+ );
69
+ }
70
+ for (const segment of segments) {
71
+ if (!objectSegment.test(segment)) {
72
+ throw new TypeIdError(
73
+ `invalid object type segment "${segment}" in ${objectType}`,
74
+ );
75
+ }
76
+ }
77
+ if (!/^v[0-9]+$/.test(segments[0] ?? "")) {
78
+ throw new TypeIdError(
79
+ `object type must start with vN (e.g. v2.core.users), got: ${objectType}`,
80
+ );
81
+ }
82
+ }
83
+
84
+ export function isObjectType(objectType: string) {
85
+ try {
86
+ assertObjectType(objectType);
87
+ return true;
88
+ } catch {
89
+ return false;
90
+ }
91
+ }
92
+
93
+ export function createTypeId(prefix: string) {
94
+ assertTypePrefix(prefix);
95
+ return typeid(prefix).toString();
96
+ }
97
+
98
+ export function isTypeId(value: string, prefix: string) {
99
+ return parseTypeId(value)?.prefix === prefix;
100
+ }
101
+
102
+ export function parseTypeId(value: string): ParsedTypeId | null {
103
+ try {
104
+ const parsed = fromString(value);
105
+ const prefix = getType(parsed);
106
+ if (!typePrefix.test(prefix)) {
107
+ return null;
108
+ }
109
+ return { prefix, suffix: getSuffix(parsed) };
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ export function createObjectRow(
116
+ objectType: string,
117
+ options: CreateObjectRowOptions,
118
+ ): ObjectRowMeta {
119
+ assertObjectType(objectType);
120
+ const prefix = options.idPrefix ?? idPrefixFromObjectType(objectType);
121
+ const now = new Date();
122
+ return {
123
+ _id: createTypeId(prefix),
124
+ _object: objectType,
125
+ _tenant: options.tenant,
126
+ _archived: false,
127
+ _createdAt: now,
128
+ _updatedAt: now,
129
+ };
130
+ }
131
+
132
+ function assertTypePrefix(prefix: string) {
133
+ if (!typePrefix.test(prefix)) {
134
+ throw new TypeIdError(`invalid type prefix: ${prefix}`);
135
+ }
136
+ }
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createTypeId } from "./id.ts";
3
+ import { assertOwnedCall, assertOwnedObject } from "./owned.ts";
4
+
5
+ const now = new Date("2026-01-01T00:00:00.000Z");
6
+ const row = {
7
+ _id: createTypeId("user"),
8
+ _object: "v2.user",
9
+ _tenant: "tenant_1",
10
+ _archived: false,
11
+ _createdAt: now,
12
+ _updatedAt: now,
13
+ actor_id: "232532fsarrw23",
14
+ active: true,
15
+ message: "wewesraewe wew eas",
16
+ };
17
+
18
+ describe("assertOwnedObject", () => {
19
+ it("accepts reserved fields plus domain columns", () => {
20
+ expect(assertOwnedObject(row)).toEqual(row);
21
+ });
22
+
23
+ it("rejects a reserved field that is not part of OwnedObjects", () => {
24
+ expect(() => assertOwnedObject({ ...row, _secret: "no" })).toThrow(
25
+ "OwnedObjects field _secret is reserved",
26
+ );
27
+ expect(() => assertOwnedObject({ ...row, _id: "user_short" })).toThrow(
28
+ "OwnedObjects _id must be a typeid",
29
+ );
30
+ expect(() =>
31
+ assertOwnedObject({
32
+ ...row,
33
+ _updatedAt: new Date("2025-01-01T00:00:00.000Z"),
34
+ }),
35
+ ).toThrow("OwnedObjects _updatedAt must be at or after _createdAt");
36
+ });
37
+ });
38
+
39
+ describe("assertOwnedCall", () => {
40
+ it("requires an insert to carry OwnedObjects", () => {
41
+ expect(() =>
42
+ assertOwnedCall("insert into notes (message) values ($1)", ["hi"]),
43
+ ).toThrow("insert must include _id");
44
+ expect(() =>
45
+ assertOwnedCall("insert into notes ", [{ message: "hi" }]),
46
+ ).toThrow("OwnedObjects _id must be a typeid");
47
+ expect(() => assertOwnedCall("insert into notes ", [row])).not.toThrow();
48
+ expect(() =>
49
+ assertOwnedCall("insert into notes ", [new InsertHelper([row])]),
50
+ ).not.toThrow();
51
+ expect(() =>
52
+ assertOwnedCall("insert into notes ", [
53
+ new InsertHelper([{ message: "hi" }]),
54
+ ]),
55
+ ).toThrow("OwnedObjects _id must be a typeid");
56
+ });
57
+
58
+ it("checks OwnedObjects fields on update and ignores reads", () => {
59
+ expect(() =>
60
+ assertOwnedCall("update notes set message = $1", [{ message: "next" }]),
61
+ ).not.toThrow();
62
+ expect(() =>
63
+ assertOwnedCall("update notes set _archived = $1", [
64
+ { _archived: "yes" },
65
+ ]),
66
+ ).toThrow("OwnedObjects _archived must be a boolean");
67
+ expect(() => assertOwnedCall("select * from notes", [])).not.toThrow();
68
+ });
69
+ });
70
+
71
+ class InsertHelper {
72
+ constructor(readonly value: readonly Record<string, unknown>[]) {}
73
+ }
@@ -0,0 +1,193 @@
1
+ import { isObjectType, parseTypeId, TypeIdError } from "./id.ts";
2
+
3
+ const reserved = new Set([
4
+ "_id",
5
+ "_object",
6
+ "_tenant",
7
+ "_archived",
8
+ "_createdAt",
9
+ "_updatedAt",
10
+ ]);
11
+
12
+ export type OwnedObject = {
13
+ _id: string;
14
+ _object: string;
15
+ _tenant: string;
16
+ _archived: boolean;
17
+ _createdAt: Date;
18
+ _updatedAt: Date;
19
+ } & Record<string, unknown>;
20
+
21
+ export function assertOwnedObject(value: unknown): OwnedObject {
22
+ const row = plainRow(value);
23
+ if (!row) {
24
+ throw new TypeIdError("OwnedObjects must be an object");
25
+ }
26
+ assertId(row._id);
27
+ assertObject(row._object);
28
+ assertTenant(row._tenant);
29
+ assertArchived(row._archived);
30
+ assertCreatedAt(row._createdAt);
31
+ assertUpdatedAt(row._createdAt, row._updatedAt);
32
+ assertDomainKeys(row);
33
+ return row as OwnedObject;
34
+ }
35
+
36
+ export function assertOwnedCall(sql: string, values: readonly unknown[]) {
37
+ const kind = writeKind(sql);
38
+ if (!kind) {
39
+ return;
40
+ }
41
+ const rows = rowsOf(values);
42
+ if (kind === "insert" && rows.length === 0) {
43
+ for (const column of reserved) {
44
+ if (!namesColumn(sql, column)) {
45
+ throw new TypeIdError(`insert must include ${column}`);
46
+ }
47
+ }
48
+ return;
49
+ }
50
+ for (const row of rows) {
51
+ if (kind === "insert") {
52
+ assertOwnedObject(row);
53
+ continue;
54
+ }
55
+ assertPresentFields(row);
56
+ }
57
+ }
58
+
59
+ function assertPresentFields(row: Record<string, unknown>) {
60
+ if ("_id" in row) {
61
+ assertId(row._id);
62
+ }
63
+ if ("_object" in row) {
64
+ assertObject(row._object);
65
+ }
66
+ if ("_tenant" in row) {
67
+ assertTenant(row._tenant);
68
+ }
69
+ if ("_archived" in row) {
70
+ assertArchived(row._archived);
71
+ }
72
+ if ("_createdAt" in row) {
73
+ assertCreatedAt(row._createdAt);
74
+ }
75
+ if ("_updatedAt" in row) {
76
+ const created = "_createdAt" in row ? row._createdAt : row._updatedAt;
77
+ assertUpdatedAt(created, row._updatedAt);
78
+ }
79
+ assertDomainKeys(row);
80
+ }
81
+
82
+ function assertId(value: unknown) {
83
+ if (typeof value !== "string" || !parseTypeId(value)) {
84
+ throw new TypeIdError("OwnedObjects _id must be a typeid");
85
+ }
86
+ }
87
+
88
+ function assertObject(value: unknown) {
89
+ if (typeof value !== "string" || !isObjectType(value)) {
90
+ throw new TypeIdError("OwnedObjects _object must be an object type");
91
+ }
92
+ }
93
+
94
+ function assertTenant(value: unknown) {
95
+ if (typeof value !== "string" || value.trim() === "") {
96
+ throw new TypeIdError("OwnedObjects _tenant must be a tenant id");
97
+ }
98
+ }
99
+
100
+ function assertArchived(value: unknown) {
101
+ if (typeof value !== "boolean") {
102
+ throw new TypeIdError("OwnedObjects _archived must be a boolean");
103
+ }
104
+ }
105
+
106
+ function assertCreatedAt(value: unknown) {
107
+ if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
108
+ throw new TypeIdError("OwnedObjects _createdAt must be a timestamp");
109
+ }
110
+ }
111
+
112
+ function assertUpdatedAt(created: unknown, updated: unknown) {
113
+ if (!(updated instanceof Date) || Number.isNaN(updated.getTime())) {
114
+ throw new TypeIdError("OwnedObjects _updatedAt must be a timestamp");
115
+ }
116
+ if (created instanceof Date && updated.getTime() < created.getTime()) {
117
+ throw new TypeIdError(
118
+ "OwnedObjects _updatedAt must be at or after _createdAt",
119
+ );
120
+ }
121
+ }
122
+
123
+ function assertDomainKeys(row: Record<string, unknown>) {
124
+ for (const key of Object.keys(row)) {
125
+ if (key.startsWith("_") && !reserved.has(key)) {
126
+ throw new TypeIdError(`OwnedObjects field ${key} is reserved`);
127
+ }
128
+ }
129
+ }
130
+
131
+ function writeKind(sql: string) {
132
+ const stripped = sql
133
+ .replace(/\/\*[\s\S]*?\*\//g, " ")
134
+ .replace(/--.*$/gm, " ")
135
+ .trim();
136
+ const word = stripped.match(/^[a-z]+/i)?.[0]?.toLowerCase();
137
+ if (word === "insert") {
138
+ return "insert";
139
+ }
140
+ if (word === "update") {
141
+ return "update";
142
+ }
143
+ if (word === "with" && /\binsert\b/i.test(stripped)) {
144
+ return "insert";
145
+ }
146
+ if (word === "with" && /\bupdate\b/i.test(stripped)) {
147
+ return "update";
148
+ }
149
+ return null;
150
+ }
151
+
152
+ function namesColumn(sql: string, column: string) {
153
+ return new RegExp(`(^|[^a-z0-9])${column}([^a-z0-9]|$)`, "i").test(sql);
154
+ }
155
+
156
+ function rowsOf(values: readonly unknown[]) {
157
+ const rows: Record<string, unknown>[] = [];
158
+ for (const value of values) {
159
+ if (Array.isArray(value)) {
160
+ rows.push(...rowsOf(value));
161
+ continue;
162
+ }
163
+ const row = plainRow(value);
164
+ if (row) {
165
+ rows.push(row);
166
+ continue;
167
+ }
168
+ const nested = helperRows(value);
169
+ if (nested) {
170
+ rows.push(...rowsOf(nested));
171
+ }
172
+ }
173
+ return rows;
174
+ }
175
+
176
+ function helperRows(value: unknown) {
177
+ if (!value || typeof value !== "object") {
178
+ return null;
179
+ }
180
+ const helper = value as { value?: unknown };
181
+ return Array.isArray(helper.value) ? helper.value : null;
182
+ }
183
+
184
+ function plainRow(value: unknown) {
185
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
186
+ return null;
187
+ }
188
+ const proto = Object.getPrototypeOf(value);
189
+ if (proto !== Object.prototype && proto !== null) {
190
+ return null;
191
+ }
192
+ return value as Record<string, unknown>;
193
+ }
@@ -0,0 +1,122 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import {
3
+ connectVault,
4
+ createVault,
5
+ setVault,
6
+ VaultError,
7
+ vaultSecret,
8
+ } from "./client.ts";
9
+
10
+ describe("createVault", () => {
11
+ afterEach(() => {
12
+ setVault(null);
13
+ });
14
+ it("reads a KV v2 secret and returns its data", async () => {
15
+ const calls: {
16
+ url: string;
17
+ token: string | null;
18
+ namespace: string | null;
19
+ }[] = [];
20
+ const client = createVault({
21
+ address: "https://vault.example/",
22
+ token: "s.token",
23
+ namespace: "apps",
24
+ fetch: async (url, init) => {
25
+ const headers = new Headers(init?.headers);
26
+ calls.push({
27
+ url,
28
+ token: headers.get("X-Vault-Token"),
29
+ namespace: headers.get("X-Vault-Namespace"),
30
+ });
31
+ return Response.json({
32
+ data: {
33
+ data: { password: "secret" },
34
+ metadata: { version: 3 },
35
+ },
36
+ });
37
+ },
38
+ });
39
+ await expect(client.secret("apps/keja")).resolves.toEqual({
40
+ password: "secret",
41
+ });
42
+ expect(calls).toEqual([
43
+ {
44
+ url: "https://vault.example/v1/secret/data/apps/keja",
45
+ token: "s.token",
46
+ namespace: "apps",
47
+ },
48
+ ]);
49
+ });
50
+
51
+ it("reports a missing secret without the token", async () => {
52
+ const client = createVault({
53
+ address: "https://vault.example",
54
+ token: "s.token",
55
+ mount: "kv",
56
+ fetch: async () =>
57
+ Response.json({ errors: ["permission denied"] }, { status: 403 }),
58
+ });
59
+ await expect(client.secret("apps/keja")).rejects.toThrow(
60
+ "Vault rejected kv/apps/keja with status 403: permission denied",
61
+ );
62
+ await expect(
63
+ createVault({
64
+ address: "https://vault.example",
65
+ token: "s.token",
66
+ fetch: async () => new Response(null, { status: 404 }),
67
+ }).secret("missing"),
68
+ ).rejects.toThrow(VaultError);
69
+ });
70
+
71
+ it("requires an address and a token", () => {
72
+ expect(() => createVault({ token: "s.token" })).toThrow(
73
+ "VAULT_ADDR is required",
74
+ );
75
+ expect(() => createVault({ address: "https://vault.example" })).toThrow(
76
+ "VAULT_TOKEN is required",
77
+ );
78
+ });
79
+
80
+ it("looks up the token before vaultSecret uses the config client", async () => {
81
+ const urls: string[] = [];
82
+ await connectVault(
83
+ {
84
+ address: "https://vault.example",
85
+ token: "s.token",
86
+ namespace: null,
87
+ mount: "secret",
88
+ },
89
+ async (url) => {
90
+ urls.push(url);
91
+ if (url.endsWith("/v1/auth/token/lookup-self")) {
92
+ return Response.json({ data: { id: "s.token" } });
93
+ }
94
+ return Response.json({ data: { data: { password: "secret" } } });
95
+ },
96
+ );
97
+ await expect(vaultSecret("apps/keja")).resolves.toEqual({
98
+ password: "secret",
99
+ });
100
+ expect(urls[0]).toBe("https://vault.example/v1/auth/token/lookup-self");
101
+ });
102
+
103
+ it("turns a rejected token lookup into a project error", async () => {
104
+ await expect(
105
+ connectVault(
106
+ {
107
+ address: "https://vault.example",
108
+ token: "s.token",
109
+ namespace: null,
110
+ mount: "secret",
111
+ },
112
+ async () =>
113
+ Response.json({ errors: ["permission denied"] }, { status: 403 }),
114
+ ),
115
+ ).rejects.toThrow(
116
+ "Vault rejected auth/token/lookup-self with status 403: permission denied",
117
+ );
118
+ expect(() => vaultSecret("apps/keja")).toThrow(
119
+ "vaultSecret requires vault",
120
+ );
121
+ });
122
+ });
@@ -0,0 +1,191 @@
1
+ import { DependencyNotMet, projectError } from "../errors/framework-errors.ts";
2
+ import { processValue } from "../process/slot.ts";
3
+ import type { VaultSettings } from "../project/config.ts";
4
+
5
+ const state = processValue("vault", () => ({
6
+ active: null as VaultSettings | null,
7
+ fetch: undefined as VaultClientOptions["fetch"] | undefined,
8
+ }));
9
+
10
+ export class VaultError extends Error {
11
+ constructor(message: string) {
12
+ super(message);
13
+ this.name = "VaultError";
14
+ }
15
+ }
16
+
17
+ export type VaultClientOptions = {
18
+ address?: string;
19
+ token?: string;
20
+ namespace?: string;
21
+ mount?: string;
22
+ fetch?: (url: string, init?: RequestInit) => Promise<Response>;
23
+ };
24
+
25
+ export function setVault(settings: VaultSettings | null) {
26
+ state.active = settings;
27
+ state.fetch = undefined;
28
+ }
29
+
30
+ export async function connectVault(
31
+ settings: VaultSettings,
32
+ fetchImpl?: VaultClientOptions["fetch"],
33
+ ) {
34
+ const fetch = fetchImpl ?? globalThis.fetch;
35
+ let response: Response;
36
+ try {
37
+ response = await fetch(`${settings.address}/v1/auth/token/lookup-self`, {
38
+ headers: vaultHeaders(settings.token, settings.namespace),
39
+ });
40
+ } catch (error) {
41
+ const detail = error instanceof Error ? error.message : "request failed";
42
+ throw projectError("vaultInit", detail);
43
+ }
44
+ if (!response.ok) {
45
+ throw projectError(
46
+ "vaultInit",
47
+ (await vaultFailure(response, "auth/token/lookup-self")).message,
48
+ );
49
+ }
50
+ setVault(settings);
51
+ state.fetch = fetchImpl;
52
+ }
53
+
54
+ export function createVault(options: VaultClientOptions = {}) {
55
+ const address = vaultAddress(options.address ?? envValue("VAULT_ADDR"));
56
+ const token = required(
57
+ options.token ?? envValue("VAULT_TOKEN"),
58
+ "VAULT_TOKEN",
59
+ );
60
+ const namespace = options.namespace ?? envValue("VAULT_NAMESPACE");
61
+ const mount = secretMount(
62
+ options.mount ?? envValue("VAULT_KV_MOUNT") ?? "secret",
63
+ );
64
+ const fetchImpl = options.fetch ?? fetch;
65
+ return {
66
+ async secret<
67
+ Data extends Record<string, unknown> = Record<string, unknown>,
68
+ >(path: string) {
69
+ const secretPath = normalizePath(path);
70
+ const response = await fetchImpl(
71
+ `${address}/v1/${mount}/data/${secretPath}`,
72
+ {
73
+ headers: vaultHeaders(token, namespace),
74
+ },
75
+ );
76
+ if (!response.ok) {
77
+ throw await vaultFailure(response, `${mount}/${secretPath}`);
78
+ }
79
+ const body = (await response.json()) as {
80
+ data?: { data?: Data };
81
+ };
82
+ if (!body.data?.data || typeof body.data.data !== "object") {
83
+ throw new VaultError(`Vault secret ${mount}/${secretPath} has no data`);
84
+ }
85
+ return body.data.data;
86
+ },
87
+ };
88
+ }
89
+
90
+ export function vaultSecret<
91
+ Data extends Record<string, unknown> = Record<string, unknown>,
92
+ >(path: string, options?: VaultClientOptions) {
93
+ if (!options) {
94
+ if (!state.active) {
95
+ throw new DependencyNotMet("vaultSecret", "vault");
96
+ }
97
+ return createVault({
98
+ address: state.active.address,
99
+ token: state.active.token,
100
+ namespace: state.active.namespace ?? undefined,
101
+ mount: state.active.mount,
102
+ fetch: state.fetch,
103
+ }).secret<Data>(path);
104
+ }
105
+ return createVault(options).secret<Data>(path);
106
+ }
107
+
108
+ function vaultAddress(value: string | null) {
109
+ if (!value) {
110
+ throw new VaultError("VAULT_ADDR is required");
111
+ }
112
+ let url: URL;
113
+ try {
114
+ url = new URL(value);
115
+ } catch {
116
+ throw new VaultError("VAULT_ADDR must be an absolute http or https URL");
117
+ }
118
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
119
+ throw new VaultError("VAULT_ADDR must be an absolute http or https URL");
120
+ }
121
+ return url.href.replace(/\/$/, "");
122
+ }
123
+
124
+ function secretMount(value: string) {
125
+ const mount = value.trim().replace(/^\/+|\/+$/g, "");
126
+ if (!/^[A-Za-z0-9_-]+$/.test(mount)) {
127
+ throw new VaultError(`invalid Vault mount: ${value}`);
128
+ }
129
+ return mount;
130
+ }
131
+
132
+ function normalizePath(path: string) {
133
+ const cleaned = path.trim().replace(/^\/+|\/+$/g, "");
134
+ const parts = cleaned.split("/");
135
+ if (
136
+ !cleaned ||
137
+ parts.some((part) => part.length === 0 || part === "." || part === "..")
138
+ ) {
139
+ throw new VaultError(`invalid Vault secret path: ${path}`);
140
+ }
141
+ return parts.map((part) => encodeURIComponent(part)).join("/");
142
+ }
143
+
144
+ function vaultHeaders(token: string, namespace: string | null) {
145
+ const headers = new Headers({ "X-Vault-Token": token });
146
+ if (namespace) {
147
+ headers.set("X-Vault-Namespace", namespace);
148
+ }
149
+ return headers;
150
+ }
151
+
152
+ async function vaultFailure(response: Response, path: string) {
153
+ const errors = await vaultErrors(response);
154
+ if (response.status === 404) {
155
+ return new VaultError(`Vault secret ${path} was not found`);
156
+ }
157
+ const detail = errors.length > 0 ? `: ${errors.join(", ")}` : "";
158
+ return new VaultError(
159
+ `Vault rejected ${path} with status ${response.status}${detail}`,
160
+ );
161
+ }
162
+
163
+ async function vaultErrors(response: Response) {
164
+ try {
165
+ const body = (await response.json()) as { errors?: unknown };
166
+ if (!Array.isArray(body.errors)) {
167
+ return [];
168
+ }
169
+ return body.errors.filter(
170
+ (item): item is string => typeof item === "string",
171
+ );
172
+ } catch {
173
+ return [];
174
+ }
175
+ }
176
+
177
+ function required(value: string | null, name: string) {
178
+ if (!value) {
179
+ throw new VaultError(`${name} is required`);
180
+ }
181
+ return value;
182
+ }
183
+
184
+ function envValue(name: string) {
185
+ const runtime = globalThis as {
186
+ Bun?: { env?: Record<string, string | undefined> };
187
+ };
188
+ const value = runtime.Bun?.env?.[name] ?? process.env[name];
189
+ const trimmed = value?.trim();
190
+ return trimmed ? trimmed : null;
191
+ }