@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,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,128 @@
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
+ transit_key: null,
89
+ transit_mount: "transit",
90
+ grant_max_exp: 900,
91
+ },
92
+ async (url) => {
93
+ urls.push(url);
94
+ if (url.endsWith("/v1/auth/token/lookup-self")) {
95
+ return Response.json({ data: { id: "s.token" } });
96
+ }
97
+ return Response.json({ data: { data: { password: "secret" } } });
98
+ },
99
+ );
100
+ await expect(vaultSecret("apps/keja")).resolves.toEqual({
101
+ password: "secret",
102
+ });
103
+ expect(urls[0]).toBe("https://vault.example/v1/auth/token/lookup-self");
104
+ });
105
+
106
+ it("turns a rejected token lookup into a project error", async () => {
107
+ await expect(
108
+ connectVault(
109
+ {
110
+ address: "https://vault.example",
111
+ token: "s.token",
112
+ namespace: null,
113
+ mount: "secret",
114
+ transit_key: null,
115
+ transit_mount: "transit",
116
+ grant_max_exp: 900,
117
+ },
118
+ async () =>
119
+ Response.json({ errors: ["permission denied"] }, { status: 403 }),
120
+ ),
121
+ ).rejects.toThrow(
122
+ "Vault rejected auth/token/lookup-self with status 403: permission denied",
123
+ );
124
+ expect(() => vaultSecret("apps/keja")).toThrow(
125
+ "vaultSecret requires vault",
126
+ );
127
+ });
128
+ });