@njinlabs/njin 0.10.2 → 0.11.0-beta.1

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 (55) hide show
  1. package/README.md +31 -0
  2. package/package.json +19 -4
  3. package/src/cli/build-assets.ts +4 -1
  4. package/src/cli/build.ts +47 -23
  5. package/src/cli/create.ts +35 -9
  6. package/src/cli/index.ts +2 -1
  7. package/src/cli/update.ts +93 -16
  8. package/src/config/module.ts +9 -0
  9. package/src/core/adapters/bun_filesystem.ts +13 -5
  10. package/src/core/adapters/s3.ts +18 -6
  11. package/src/core/admin_schema.ts +54 -0
  12. package/src/core/banner.ts +18 -4
  13. package/src/core/config.ts +49 -11
  14. package/src/core/helper.ts +4 -1
  15. package/src/core/html_page.ts +63 -0
  16. package/src/core/model/data_type/array.ts +16 -5
  17. package/src/core/model/data_type/boolean.ts +4 -1
  18. package/src/core/model/data_type/date.ts +4 -1
  19. package/src/core/model/data_type/email.ts +4 -1
  20. package/src/core/model/data_type/file.ts +17 -4
  21. package/src/core/model/data_type/multi_file.ts +9 -4
  22. package/src/core/model/data_type/numeric.ts +4 -1
  23. package/src/core/model/data_type/object.ts +8 -2
  24. package/src/core/model/data_type/relation.ts +21 -5
  25. package/src/core/model/data_type/relation_many.ts +24 -6
  26. package/src/core/model/data_type/richtext.ts +4 -1
  27. package/src/core/model/data_type/select.ts +17 -3
  28. package/src/core/model/data_type/text.ts +4 -1
  29. package/src/core/model/hooks.ts +42 -15
  30. package/src/core/model/index.ts +101 -37
  31. package/src/core/path_guard.ts +10 -3
  32. package/src/core/plugin.ts +8 -1
  33. package/src/core/public_url.ts +24 -0
  34. package/src/core/route.ts +2 -1
  35. package/src/core/vars/index.ts +22 -8
  36. package/src/core/worker.ts +22 -6
  37. package/src/models/file.ts +1 -1
  38. package/src/models/user.ts +1 -1
  39. package/src/modules/admin.ts +2 -2
  40. package/src/modules/analytics.ts +60 -25
  41. package/src/modules/api.ts +67 -95
  42. package/src/modules/auth.ts +25 -8
  43. package/src/modules/elysia.ts +1 -1
  44. package/src/modules/file.ts +50 -33
  45. package/src/modules/img.ts +33 -14
  46. package/src/modules/logger.ts +1 -1
  47. package/src/modules/mcp.ts +437 -0
  48. package/src/modules/mcp_token.ts +295 -0
  49. package/src/modules/mcp_upload.ts +313 -0
  50. package/src/modules/oauth.ts +550 -0
  51. package/src/modules/setup.ts +13 -5
  52. package/src/modules/surreal.ts +43 -13
  53. package/src/modules/users.ts +23 -5
  54. package/src/modules/vars.ts +1 -1
  55. package/src/modules/view.ts +88 -54
@@ -1,61 +1,11 @@
1
- import { getConfig } from "../core/config";
2
- import { makeModule } from "../core/module";
3
1
  import Elysia from "elysia";
4
2
  import z from "zod";
3
+ import { toAdminSchema } from "../core/admin_schema";
4
+ import { getConfig } from "../core/config";
5
+ import { makeModule } from "../core/module";
5
6
  import auth from "./auth";
6
7
  import elysia from "./elysia";
7
8
 
8
- // Removes fields marked hideForm: true from a JSON schema's properties/required,
9
- // recursing into nested object/array shapes so hidden fields never reach the admin panel.
10
- const stripHiddenFields = (node: any): void => {
11
- if (!node || typeof node !== "object") return;
12
-
13
- if (node.properties) {
14
- for (const [key, prop] of Object.entries(node.properties as Record<string, any>)) {
15
- if (prop?.hideForm) {
16
- delete node.properties[key];
17
- if (Array.isArray(node.required)) {
18
- node.required = node.required.filter((r: string) => r !== key);
19
- }
20
- } else {
21
- stripHiddenFields(prop);
22
- }
23
- }
24
- }
25
-
26
- if (node.items) stripHiddenFields(node.items);
27
- };
28
-
29
- // Shared by both models and vars groups on the /api/schema endpoint — strips
30
- // non-JSON-representable renderAs types (relation/multi_relation/file) into
31
- // plain JSON-schema shapes the admin panel can render a form from, and drops
32
- // any field marked hideForm: true so it never reaches the admin panel.
33
- const toAdminSchema = (schema: z.ZodObject) => {
34
- const jsonSchema = schema.toJSONSchema({
35
- unrepresentable: "any",
36
- override: (ctx) => {
37
- if (ctx.jsonSchema.renderAs === "relation") {
38
- ctx.jsonSchema.type = "object";
39
- ctx.jsonSchema.properties = {
40
- id: {
41
- type: "string",
42
- },
43
- };
44
- ctx.jsonSchema.required = ["id"];
45
- ctx.jsonSchema.additionalProperties = {};
46
- } else if (ctx.jsonSchema.renderAs === "multi_relation") {
47
- (ctx.jsonSchema.items! as any).type = "string";
48
- } else if (ctx.jsonSchema.renderAs === "file") {
49
- ctx.jsonSchema.type = "string";
50
- }
51
- },
52
- });
53
-
54
- stripHiddenFields(jsonSchema);
55
-
56
- return jsonSchema;
57
- };
58
-
59
9
  const api = makeModule(() => {
60
10
  const fn = () => {};
61
11
 
@@ -75,43 +25,45 @@ const api = makeModule(() => {
75
25
  // every listener file's .listen() registration before any route goes live.
76
26
  for (const promise of events) await promise();
77
27
 
78
- const controller = new Elysia({ prefix: "/api/schema" }).use(authPlugin.plugin).get(
79
- "/",
80
- async () => {
81
- const allModels = await Promise.all(
82
- models.map((model) =>
83
- (async () => {
84
- const { default: data } = await model();
85
-
86
- return {
87
- name: data.name,
88
- prefix: data.prefix,
89
- schema: toAdminSchema(data.validation),
90
- };
91
- })(),
92
- ),
93
- );
94
-
95
- const allVars = await Promise.all(
96
- varsGroups.map((varsGroup) =>
97
- (async () => {
98
- const { default: group } = await varsGroup();
99
-
100
- return {
101
- name: group.name,
102
- prefix: group.prefix,
103
- schema: toAdminSchema(group.validation),
104
- };
105
- })(),
106
- ),
107
- );
108
-
109
- return { data: allModels, vars: allVars };
110
- },
111
- {
112
- auth: true,
113
- },
114
- );
28
+ const controller = new Elysia({ prefix: "/api/schema" })
29
+ .use(authPlugin.plugin)
30
+ .get(
31
+ "/",
32
+ async () => {
33
+ const allModels = await Promise.all(
34
+ models.map((model) =>
35
+ (async () => {
36
+ const { default: data } = await model();
37
+
38
+ return {
39
+ name: data.name,
40
+ prefix: data.prefix,
41
+ schema: toAdminSchema(data.validation),
42
+ };
43
+ })(),
44
+ ),
45
+ );
46
+
47
+ const allVars = await Promise.all(
48
+ varsGroups.map((varsGroup) =>
49
+ (async () => {
50
+ const { default: group } = await varsGroup();
51
+
52
+ return {
53
+ name: group.name,
54
+ prefix: group.prefix,
55
+ schema: toAdminSchema(group.validation),
56
+ };
57
+ })(),
58
+ ),
59
+ );
60
+
61
+ return { data: allModels, vars: allVars };
62
+ },
63
+ {
64
+ auth: true,
65
+ },
66
+ );
115
67
 
116
68
  elysia().use(controller);
117
69
 
@@ -185,11 +137,25 @@ const api = makeModule(() => {
185
137
  )
186
138
  .get(
187
139
  "/",
188
- async ({ query: { search, page, limit, sort, order, populate, filters } }) => {
140
+ async ({
141
+ query: { search, page, limit, sort, order, populate, filters },
142
+ }) => {
189
143
  const populateOpt =
190
- populate === "none" ? ("none" as const) : populate ? populate.split(",").map((s) => s.trim()) : undefined;
191
-
192
- return model.read({ search, page, limit, sort, order, populate: populateOpt, filters });
144
+ populate === "none"
145
+ ? ("none" as const)
146
+ : populate
147
+ ? populate.split(",").map((s) => s.trim())
148
+ : undefined;
149
+
150
+ return model.read({
151
+ search,
152
+ page,
153
+ limit,
154
+ sort,
155
+ order,
156
+ populate: populateOpt,
157
+ filters,
158
+ });
193
159
  },
194
160
  {
195
161
  auth: true,
@@ -201,7 +167,13 @@ const api = makeModule(() => {
201
167
  order: z.enum(["asc", "desc"]).default("asc"),
202
168
  populate: z.coerce.string().optional(),
203
169
  filters: z
204
- .record(z.string(), z.union([z.coerce.string(), z.record(z.string(), z.coerce.string())]))
170
+ .record(
171
+ z.string(),
172
+ z.union([
173
+ z.coerce.string(),
174
+ z.record(z.string(), z.coerce.string()),
175
+ ]),
176
+ )
205
177
  .optional(),
206
178
  }),
207
179
  },
@@ -1,9 +1,9 @@
1
1
  import bearer from "@elysia/bearer";
2
- import { makeModule } from "../core/module";
3
2
  import Elysia, { status } from "elysia";
4
3
  import moment from "moment";
5
4
  import { eq, RecordId, Table, Uuid } from "surrealdb";
6
5
  import z from "zod";
6
+ import { makeModule } from "../core/module";
7
7
  import elysia from "./elysia";
8
8
  import surreal from "./surreal";
9
9
 
@@ -30,19 +30,26 @@ const auth = makeModule(() => {
30
30
 
31
31
  const [_table, tokenId, plainToken] = bearer.split(":");
32
32
 
33
- const token = await surreal().select<Token>(new RecordId(table, tokenId!)).fetch("user");
33
+ const token = await surreal()
34
+ .select<Token>(new RecordId(table, tokenId!))
35
+ .fetch("user");
34
36
 
35
37
  if (!token) {
36
38
  throw new Error("Unauthorized");
37
39
  }
38
40
 
39
- const hash = new Bun.CryptoHasher("sha256").update(plainToken!).digest("utf8");
41
+ const hash = new Bun.CryptoHasher("sha256")
42
+ .update(plainToken!)
43
+ .digest("utf8");
40
44
 
41
45
  if (hash !== token.hash) {
42
46
  throw new Error("Unauthorized");
43
47
  }
44
48
 
45
- const { password: _, ...safeUser } = token.user as typeof token.user & { password: string };
49
+ const { password: _, ...safeUser } =
50
+ token.user as typeof token.user & {
51
+ password: string;
52
+ };
46
53
 
47
54
  return {
48
55
  user: { ...safeUser, tokenId: token.id },
@@ -79,7 +86,11 @@ const auth = makeModule(() => {
79
86
  prefix: "/api/auth",
80
87
  })
81
88
  .use((await fn()).plugin)
82
- .get("/check-token", ({ user: { tokenId: _, ...user } }) => ({ data: user }), { auth: true })
89
+ .get(
90
+ "/check-token",
91
+ ({ user: { tokenId: _, ...user } }) => ({ data: user }),
92
+ { auth: true },
93
+ )
83
94
  .delete(
84
95
  "/logout",
85
96
  async ({ user: { tokenId, ...user } }) => {
@@ -93,7 +104,9 @@ const auth = makeModule(() => {
93
104
  "/login",
94
105
  async ({ body }) => {
95
106
  try {
96
- const [data] = await surreal().select<Token["user"]>(user.table).where(eq("email", body.email));
107
+ const [data] = await surreal()
108
+ .select<Token["user"]>(user.table)
109
+ .where(eq("email", body.email));
97
110
 
98
111
  if (!data) {
99
112
  throw new Error("Unauthorized");
@@ -108,7 +121,9 @@ const auth = makeModule(() => {
108
121
  const [token] = await surreal()
109
122
  .create<Token>(table)
110
123
  .content({
111
- hash: new Bun.CryptoHasher("sha256").update(plainToken).digest("utf8"),
124
+ hash: new Bun.CryptoHasher("sha256")
125
+ .update(plainToken)
126
+ .digest("utf8"),
112
127
  user: data.id as unknown as Token["user"],
113
128
  createdAt: moment().toISOString(),
114
129
  updatedAt: moment().toISOString(),
@@ -116,7 +131,9 @@ const auth = makeModule(() => {
116
131
 
117
132
  await surreal().relate(token!.id, new Table("user_token"), data.id);
118
133
 
119
- const { password: _, ...safeData } = data as typeof data & { password: string };
134
+ const { password: _, ...safeData } = data as typeof data & {
135
+ password: string;
136
+ };
120
137
 
121
138
  return {
122
139
  data: {
@@ -1,9 +1,9 @@
1
1
  import cors from "@elysia/cors";
2
+ import Elysia, { type AnyElysia, status, ValidationError } from "elysia";
2
3
  import { getConfig } from "../core/config";
3
4
  import { UniqueConstraintError } from "../core/model";
4
5
  import { makeModule } from "../core/module";
5
6
  import { serveWorker } from "../core/worker";
6
- import Elysia, { status, ValidationError, type AnyElysia } from "elysia";
7
7
  import logger from "./logger";
8
8
 
9
9
  // Elysia's query parser only understands object/record-shaped query schemas as a single
@@ -1,11 +1,11 @@
1
+ import { resolve } from "node:path";
2
+ import Elysia from "elysia";
3
+ import { RecordId } from "surrealdb";
4
+ import z from "zod";
1
5
  import { getConfig } from "../core/config";
2
6
  import type { makeModel } from "../core/model";
3
7
  import { makeModule } from "../core/module";
4
8
  import { resolveSafePath } from "../core/path_guard";
5
- import Elysia from "elysia";
6
- import { resolve } from "node:path";
7
- import { RecordId } from "surrealdb";
8
- import z from "zod";
9
9
  import auth from "./auth";
10
10
  import elysia from "./elysia";
11
11
  import surreal from "./surreal";
@@ -22,7 +22,9 @@ export type FileUpload<Meta> = {
22
22
  };
23
23
 
24
24
  export interface FileAdapter<Meta> {
25
- write: (file: File) => Promise<Omit<FileUpload<Meta>, "id" | "createdAt" | "updatedAt">>;
25
+ write: (
26
+ file: File,
27
+ ) => Promise<Omit<FileUpload<Meta>, "id" | "createdAt" | "updatedAt">>;
26
28
  unlink: (file: FileUpload<Meta>) => Promise<void>;
27
29
  meta: Meta;
28
30
  // Only set by filesystem-backed adapters — tells this module where to serve
@@ -33,14 +35,33 @@ export interface FileAdapter<Meta> {
33
35
 
34
36
  const file = makeModule(() => {
35
37
  let model: ReturnType<typeof makeModel>;
38
+ // Set in init() once the file model is loaded — shared by the REST routes below and any
39
+ // other caller (e.g. the MCP upload flow) so every path goes through the same adapter.
40
+ let upload: (file: File) => Promise<unknown>;
41
+ let remove: (id: string) => Promise<unknown>;
36
42
 
37
- const fn = () => ({ model });
43
+ const fn = () => ({ model, upload, remove });
38
44
 
39
45
  fn.init = async () => {
40
46
  const { default: baseModel } = await import("../models/file");
41
47
 
42
48
  type FileUploadCurrent = Awaited<ReturnType<typeof baseModel.create>>;
43
49
 
50
+ upload = async (file) =>
51
+ surreal()
52
+ .create<Omit<FileUploadCurrent, "id">>(baseModel.table)
53
+ .content(await getConfig().adapters.file.write(file));
54
+
55
+ remove = async (id) => {
56
+ const data = await surreal().delete<FileUploadCurrent>(
57
+ new RecordId(baseModel.table, id),
58
+ );
59
+
60
+ await getConfig().adapters.file.unlink(data);
61
+
62
+ return data;
63
+ };
64
+
44
65
  const controller = new Elysia({ prefix: "/api/file" })
45
66
  .use((await auth()).plugin)
46
67
  .get(
@@ -62,11 +83,7 @@ const file = makeModule(() => {
62
83
  .delete(
63
84
  "/:id",
64
85
  async ({ params }) => {
65
- const data = await surreal().delete<FileUploadCurrent>(new RecordId(baseModel.table, params.id));
66
-
67
- await getConfig().adapters.file.unlink(data);
68
-
69
- return { data };
86
+ return { data: await remove(params.id) };
70
87
  },
71
88
  {
72
89
  params: z.object({
@@ -78,11 +95,7 @@ const file = makeModule(() => {
78
95
  .post(
79
96
  "/",
80
97
  async ({ body }) => {
81
- const data = await surreal()
82
- .create<Omit<FileUploadCurrent, "id">>(baseModel.table)
83
- .content(await getConfig().adapters.file.write(body.file));
84
-
85
- return { data };
98
+ return { data: await upload(body.file) };
86
99
  },
87
100
  {
88
101
  auth: true,
@@ -99,23 +112,27 @@ const file = makeModule(() => {
99
112
  // already-absolute dir must win outright rather than get nested under rootDir.
100
113
  const uploadsDir = resolve(getConfig().rootDir, adapterDir);
101
114
 
102
- const uploadsController = new Elysia().get("/uploads/*", async ({ params }) => {
103
- // Elysia leaves wildcard params percent-encoded — decode before touching the filesystem.
104
- let decoded: string;
105
- try {
106
- decoded = decodeURIComponent(params["*"]);
107
- } catch {
108
- return new Response("Not Found", { status: 404 });
109
- }
110
-
111
- const requested = resolveSafePath(uploadsDir, decoded);
112
- if (!requested) return new Response("Not Found", { status: 404 });
113
-
114
- const file = Bun.file(requested);
115
- if (!(await file.exists())) return new Response("Not Found", { status: 404 });
116
-
117
- return file;
118
- });
115
+ const uploadsController = new Elysia().get(
116
+ "/uploads/*",
117
+ async ({ params }) => {
118
+ // Elysia leaves wildcard params percent-encoded — decode before touching the filesystem.
119
+ let decoded: string;
120
+ try {
121
+ decoded = decodeURIComponent(params["*"]);
122
+ } catch {
123
+ return new Response("Not Found", { status: 404 });
124
+ }
125
+
126
+ const requested = resolveSafePath(uploadsDir, decoded);
127
+ if (!requested) return new Response("Not Found", { status: 404 });
128
+
129
+ const file = Bun.file(requested);
130
+ if (!(await file.exists()))
131
+ return new Response("Not Found", { status: 404 });
132
+
133
+ return file;
134
+ },
135
+ );
119
136
 
120
137
  elysia().use(controller).use(uploadsController);
121
138
  } else {
@@ -1,10 +1,13 @@
1
+ import Elysia from "elysia";
1
2
  import { getConfig } from "../core/config";
2
3
  import { makeModule } from "../core/module";
3
- import Elysia from "elysia";
4
4
  import elysia from "./elysia";
5
5
  import view from "./view";
6
6
 
7
- export function isAllowed(imageUrl: string, allowedHosts: string[] = getConfig().img.hosts): boolean {
7
+ export function isAllowed(
8
+ imageUrl: string,
9
+ allowedHosts: string[] = getConfig().img.hosts,
10
+ ): boolean {
8
11
  if (imageUrl.startsWith("/")) return true;
9
12
 
10
13
  let parsed: URL;
@@ -21,7 +24,11 @@ export function isAllowed(imageUrl: string, allowedHosts: string[] = getConfig()
21
24
  // header, or allowing loopback unconditionally, would turn this into an SSRF
22
25
  // primitive (an attacker controls both the `url` query param and their own
23
26
  // request's `Host` header), so neither is honored here.
24
- if (process.env.NODE_ENV !== "production" && (imageHostname === "localhost" || imageHostname === "127.0.0.1")) return true;
27
+ if (
28
+ process.env.NODE_ENV !== "production" &&
29
+ (imageHostname === "localhost" || imageHostname === "127.0.0.1")
30
+ )
31
+ return true;
25
32
 
26
33
  // Every other host: require explicit whitelist
27
34
  return allowedHosts.includes(imageHostname);
@@ -42,13 +49,16 @@ const img = makeModule(() => {
42
49
  const fn = () => {};
43
50
 
44
51
  fn.init = async () => {
45
- view().global("imgOptimize", (url: string, options?: { w?: number; h?: number; q?: number }) => {
46
- const params = new URLSearchParams({ url });
47
- if (options?.w != null) params.set("w", String(options.w));
48
- if (options?.h != null) params.set("h", String(options.h));
49
- if (options?.q != null) params.set("q", String(options.q));
50
- return `/img?${params.toString()}`;
51
- });
52
+ view().global(
53
+ "imgOptimize",
54
+ (url: string, options?: { w?: number; h?: number; q?: number }) => {
55
+ const params = new URLSearchParams({ url });
56
+ if (options?.w != null) params.set("w", String(options.w));
57
+ if (options?.h != null) params.set("h", String(options.h));
58
+ if (options?.q != null) params.set("q", String(options.q));
59
+ return `/img?${params.toString()}`;
60
+ },
61
+ );
52
62
 
53
63
  const controller = new Elysia();
54
64
 
@@ -95,7 +105,9 @@ const img = makeModule(() => {
95
105
  // pathname+query, but a handler further down that inspects request.url (absolute-URL
96
106
  // generation, host-based logic, ...) should see the same scheme/host this request
97
107
  // actually arrived on, not a fake one that may not match across environments.
98
- const resp = await elysia().handle(new Request(`${new URL(request.url).origin}${url}`));
108
+ const resp = await elysia().handle(
109
+ new Request(`${new URL(request.url).origin}${url}`),
110
+ );
99
111
  if (!resp.ok) return new Response("Image not found", { status: 404 });
100
112
  imageData = await resp.arrayBuffer();
101
113
  } else {
@@ -107,7 +119,8 @@ const img = makeModule(() => {
107
119
  } catch {
108
120
  return new Response("Failed to fetch image", { status: 502 });
109
121
  }
110
- if (!resp.ok) return new Response("Failed to fetch image", { status: 502 });
122
+ if (!resp.ok)
123
+ return new Response("Failed to fetch image", { status: 502 });
111
124
  imageData = await resp.arrayBuffer();
112
125
  }
113
126
 
@@ -117,13 +130,19 @@ const img = makeModule(() => {
117
130
  // Bun.Image.resize() takes width first and derives height from aspect ratio
118
131
  // when height is omitted — there's no height-only equivalent, so width-only
119
132
  // and width+height both go through this branch unchanged.
120
- pipeline.resize(width, height, { fit: "inside", withoutEnlargement: true });
133
+ pipeline.resize(width, height, {
134
+ fit: "inside",
135
+ withoutEnlargement: true,
136
+ });
121
137
  } else if (height) {
122
138
  // Height-only request — derive a proportional width from the source dimensions
123
139
  // since resize() requires a width argument.
124
140
  const meta = await pipeline.metadata();
125
141
  const derivedWidth = Math.round((meta.width / meta.height) * height);
126
- pipeline.resize(derivedWidth, height, { fit: "inside", withoutEnlargement: true });
142
+ pipeline.resize(derivedWidth, height, {
143
+ fit: "inside",
144
+ withoutEnlargement: true,
145
+ });
127
146
  }
128
147
 
129
148
  const output = await pipeline.webp({ quality }).buffer();
@@ -1,6 +1,6 @@
1
- import { makeModule } from "../core/module";
2
1
  import pino from "pino";
3
2
  import pretty from "pino-pretty";
3
+ import { makeModule } from "../core/module";
4
4
 
5
5
  const logger = makeModule(() => {
6
6
  let p: pino.Logger;