@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,7 +1,7 @@
1
- import surreal from "../../modules/surreal";
2
1
  import moment from "moment";
3
2
  import { RecordId, Table, type Values } from "surrealdb";
4
- import { z } from "zod";
3
+ import type { z } from "zod";
4
+ import surreal from "../../modules/surreal";
5
5
  import { runAfterHooks, runBeforeDestroyHooks, runBeforeHooks } from "./hooks";
6
6
 
7
7
  // See the containment-boost comment in read()'s relevanceSelect below for why this exists and
@@ -53,18 +53,20 @@ export type ReadOptions = {
53
53
  filters?: Record<string, FilterValue>;
54
54
  };
55
55
 
56
-
57
56
  // Whitelist of operators — unknown operators are silently dropped, never interpolated.
58
- const OPERATORS: Record<FilterOperator, (field: string, param: string) => string> = {
59
- $eq: (f, p) => `${f} = $${p}`,
60
- $ne: (f, p) => `${f} != $${p}`,
61
- $gt: (f, p) => `${f} > $${p}`,
62
- $gte: (f, p) => `${f} >= $${p}`,
63
- $lt: (f, p) => `${f} < $${p}`,
64
- $lte: (f, p) => `${f} <= $${p}`,
65
- $contains: (f, p) => `string::contains(string::lowercase(${f}), $${p})`,
57
+ const OPERATORS: Record<
58
+ FilterOperator,
59
+ (field: string, param: string) => string
60
+ > = {
61
+ $eq: (f, p) => `${f} = $${p}`,
62
+ $ne: (f, p) => `${f} != $${p}`,
63
+ $gt: (f, p) => `${f} > $${p}`,
64
+ $gte: (f, p) => `${f} >= $${p}`,
65
+ $lt: (f, p) => `${f} < $${p}`,
66
+ $lte: (f, p) => `${f} <= $${p}`,
67
+ $contains: (f, p) => `string::contains(string::lowercase(${f}), $${p})`,
66
68
  $startsWith: (f, p) => `string::starts_with(string::lowercase(${f}), $${p})`,
67
- $in: (f, p) => `${f} CONTAINS $${p}`,
69
+ $in: (f, p) => `${f} CONTAINS $${p}`,
68
70
  };
69
71
 
70
72
  const RELATION_RENDER_AS = ["relation", "multi_relation", "file", "multi_file"];
@@ -78,19 +80,31 @@ const schemaRegistry = new Map<string, z.ZodObject>();
78
80
 
79
81
  export type ResolvedSearchField =
80
82
  | { kind: "flat"; field: string }
81
- | { kind: "nested"; local: string; targetPrefix: string; targetField: string; multi: boolean };
83
+ | {
84
+ kind: "nested";
85
+ local: string;
86
+ targetPrefix: string;
87
+ targetField: string;
88
+ multi: boolean;
89
+ };
82
90
 
83
91
  // Parses searchFields entries into flat field names and single-hop nested
84
92
  // "relationField.targetField" references, validating relation-family fields and
85
93
  // (when the target model has already registered itself) the target field's existence
86
94
  // synchronously — so a bad reference throws at model-definition time, not per-request.
87
- export const resolveSearchPlan = (schema: z.ZodObject, searchFields: string[], modelName: string): ResolvedSearchField[] => {
95
+ export const resolveSearchPlan = (
96
+ schema: z.ZodObject,
97
+ searchFields: string[],
98
+ modelName: string,
99
+ ): ResolvedSearchField[] => {
88
100
  return searchFields.map((raw): ResolvedSearchField => {
89
101
  const dot = raw.indexOf(".");
90
102
  if (dot === -1) return { kind: "flat", field: raw };
91
103
 
92
104
  if (raw.indexOf(".", dot + 1) !== -1) {
93
- throw new Error(`Model "${modelName}": searchFields entry "${raw}" has more than one level of nesting — only a single relation hop is supported.`);
105
+ throw new Error(
106
+ `Model "${modelName}": searchFields entry "${raw}" has more than one level of nesting — only a single relation hop is supported.`,
107
+ );
94
108
  }
95
109
 
96
110
  const local = raw.slice(0, dot);
@@ -98,13 +112,17 @@ export const resolveSearchPlan = (schema: z.ZodObject, searchFields: string[], m
98
112
  const meta = (schema.shape[local] as z.ZodType | undefined)?.meta() as any;
99
113
 
100
114
  if (!meta || !RELATION_RENDER_AS.includes(meta.renderAs)) {
101
- throw new Error(`Model "${modelName}": searchFields entry "${raw}" references "${local}", which is not a relation/file field on this model's schema.`);
115
+ throw new Error(
116
+ `Model "${modelName}": searchFields entry "${raw}" references "${local}", which is not a relation/file field on this model's schema.`,
117
+ );
102
118
  }
103
119
 
104
120
  const targetPrefix = meta.model as string;
105
121
  const targetSchema = schemaRegistry.get(targetPrefix);
106
122
  if (targetSchema && !(targetField in targetSchema.shape)) {
107
- throw new Error(`Model "${modelName}": searchFields entry "${raw}" references field "${targetField}", which does not exist on model "${targetPrefix}".`);
123
+ throw new Error(
124
+ `Model "${modelName}": searchFields entry "${raw}" references field "${targetField}", which does not exist on model "${targetPrefix}".`,
125
+ );
108
126
  }
109
127
 
110
128
  return {
@@ -112,7 +130,8 @@ export const resolveSearchPlan = (schema: z.ZodObject, searchFields: string[], m
112
130
  local,
113
131
  targetPrefix,
114
132
  targetField,
115
- multi: meta.renderAs === "multi_relation" || meta.renderAs === "multi_file",
133
+ multi:
134
+ meta.renderAs === "multi_relation" || meta.renderAs === "multi_file",
116
135
  };
117
136
  });
118
137
  };
@@ -145,7 +164,11 @@ export const makeModel = <Rules extends z.ZodObject>(
145
164
 
146
165
  const relationFieldSet = new Set(relationFields);
147
166
 
148
- const searchPlan = resolveSearchPlan(config.schema, config.searchFields, config.name);
167
+ const searchPlan = resolveSearchPlan(
168
+ config.schema,
169
+ config.searchFields,
170
+ config.name,
171
+ );
149
172
 
150
173
  const uniqueFields = Object.entries(config.schema.shape)
151
174
  .filter(([, v]) => (v as z.ZodType).meta()?.unique === true)
@@ -161,19 +184,29 @@ export const makeModel = <Rules extends z.ZodObject>(
161
184
  const filterableFieldSet = new Set(Object.keys(config.schema.shape));
162
185
 
163
186
  // field must be a known unique field — prevents arbitrary field injection
164
- const isDuplicate = async (field: string, value: unknown, excludeId?: string) => {
187
+ const isDuplicate = async (
188
+ field: string,
189
+ value: unknown,
190
+ excludeId?: string,
191
+ ) => {
165
192
  if (!uniqueFieldSet.has(field)) return false;
166
193
 
167
194
  const excludeClause = excludeId ? "AND id != $excludeId" : "";
168
195
  const [[row]] = await surreal().query<[{ count: number }[]]>(
169
196
  `SELECT count() AS count FROM ${prefix} WHERE ${field} = $value ${excludeClause} GROUP ALL`,
170
- { value, excludeId: excludeId ? new RecordId(table, excludeId) : undefined },
197
+ {
198
+ value,
199
+ excludeId: excludeId ? new RecordId(table, excludeId) : undefined,
200
+ },
171
201
  );
172
202
 
173
203
  return (row?.count ?? 0) > 0;
174
204
  };
175
205
 
176
- const assertUnique = async (data: Record<string, unknown>, excludeId?: string) => {
206
+ const assertUnique = async (
207
+ data: Record<string, unknown>,
208
+ excludeId?: string,
209
+ ) => {
177
210
  for (const field of uniqueFields) {
178
211
  const value = data[field];
179
212
  if (value === undefined) continue; // partial update without this field — nothing to check
@@ -185,13 +218,22 @@ export const makeModel = <Rules extends z.ZodObject>(
185
218
  };
186
219
 
187
220
  const create = async (data: Values<Data>) => {
188
- const merged = (await runBeforeHooks("beforeCreate", prefix, data as Record<string, unknown>, {})) as Values<Data>;
221
+ const merged = (await runBeforeHooks(
222
+ "beforeCreate",
223
+ prefix,
224
+ data as Record<string, unknown>,
225
+ {},
226
+ )) as Values<Data>;
189
227
 
190
228
  await assertUnique(merged);
191
229
 
192
230
  const record = (await surreal()
193
231
  .create<Data>(table)
194
- .content({ ...merged, createdAt: moment().toISOString(), updatedAt: moment().toISOString() })
232
+ .content({
233
+ ...merged,
234
+ createdAt: moment().toISOString(),
235
+ updatedAt: moment().toISOString(),
236
+ })
195
237
  .output("after")
196
238
  .then(([data]) => data)) as Returning;
197
239
 
@@ -246,7 +288,10 @@ export const makeModel = <Rules extends z.ZodObject>(
246
288
  whereParts.push(`${key} = $f_${key}`);
247
289
  } else {
248
290
  // Operator form: filters[field][$op]=value
249
- for (const [op, opValue] of Object.entries(value) as [FilterOperator, string][]) {
291
+ for (const [op, opValue] of Object.entries(value) as [
292
+ FilterOperator,
293
+ string,
294
+ ][]) {
250
295
  const builder = OPERATORS[op]; // strict whitelist — unknown ops get undefined
251
296
  if (!builder || opValue === undefined) continue;
252
297
 
@@ -264,7 +309,12 @@ export const makeModel = <Rules extends z.ZodObject>(
264
309
  const where = whereParts.length ? `WHERE ${whereParts.join(" AND ")}` : "";
265
310
  // id/createdAt/updatedAt are always present on every record but aren't part of the
266
311
  // user-defined schema shape (they're injected in create()/update()) — allow sorting by them too.
267
- const sortableFields = new Set([...Object.keys(config.schema.shape), "id", "createdAt", "updatedAt"]);
312
+ const sortableFields = new Set([
313
+ ...Object.keys(config.schema.shape),
314
+ "id",
315
+ "createdAt",
316
+ "updatedAt",
317
+ ]);
268
318
  const hasExplicitSort = Boolean(sort && sortableFields.has(sort));
269
319
  // An explicit sort always wins; otherwise, when searching, rank by relevance instead of
270
320
  // leaving result order unspecified. `ORDER BY` only accepts a bare identifier here, not a
@@ -296,7 +346,8 @@ export const makeModel = <Rules extends z.ZodObject>(
296
346
  // data routinely writes a multi-word term as one run-together token (e.g. "REDWING 2415..."
297
347
  // for "Red Wing") — a plain substring check against "red wing" (with the space) would miss
298
348
  // that despite it being a stronger match than most ngram overlaps.
299
- const useRelevance = !hasExplicitSort && Boolean(search && searchPlan.length);
349
+ const useRelevance =
350
+ !hasExplicitSort && Boolean(search && searchPlan.length);
300
351
  const orderBy = hasExplicitSort
301
352
  ? `ORDER BY ${sort} ${order === "desc" ? "DESC" : "ASC"}`
302
353
  : useRelevance
@@ -308,11 +359,14 @@ export const makeModel = <Rules extends z.ZodObject>(
308
359
  ? `, (${searchPlan
309
360
  .map((e, i) => {
310
361
  const n = i + 1;
311
- const boost = (field: string) => `(IF ${containmentCheck(field)} THEN ${CONTAINMENT_BOOST} ELSE 0 END)`;
362
+ const boost = (field: string) =>
363
+ `(IF ${containmentCheck(field)} THEN ${CONTAINMENT_BOOST} ELSE 0 END)`;
312
364
  if (e.kind === "flat") {
313
365
  return `(search::score(${n}) + ${boost(e.field)})`;
314
366
  }
315
- const idFilter = e.multi ? `id IN $parent.${e.local}` : `id = $parent.${e.local}`;
367
+ const idFilter = e.multi
368
+ ? `id IN $parent.${e.local}`
369
+ : `id = $parent.${e.local}`;
316
370
  return `math::sum((SELECT VALUE (search::score(${n}) + ${boost(e.targetField)}) FROM ${e.targetPrefix} WHERE ${idFilter} AND ${e.targetField} @${n}@ $search))`;
317
371
  })
318
372
  .join(" + ")}) AS __relevance`
@@ -328,7 +382,9 @@ export const makeModel = <Rules extends z.ZodObject>(
328
382
  const fetch = fetchFields.length ? `FETCH ${fetchFields.join(", ")}` : "";
329
383
  const start = (page - 1) * pageLimit;
330
384
 
331
- const [rows, [countRow]] = await surreal().query<[(Returning & { __relevance?: number })[], { count: number }[]]>(
385
+ const [rows, [countRow]] = await surreal().query<
386
+ [(Returning & { __relevance?: number })[], { count: number }[]]
387
+ >(
332
388
  `SELECT *${relevanceSelect} FROM ${prefix} ${where} ${orderBy} LIMIT ${pageLimit} START ${start} ${fetch};
333
389
  SELECT count() as count FROM ${prefix} ${where} GROUP ALL`,
334
390
  params,
@@ -355,14 +411,20 @@ export const makeModel = <Rules extends z.ZodObject>(
355
411
 
356
412
  const show = (id: string) => {
357
413
  let q = surreal().select<Data>(new RecordId(table, id));
358
- if (relationFields.length) q = q.fetch(...(relationFields as [string, ...string[]])) as typeof q;
414
+ if (relationFields.length)
415
+ q = q.fetch(...(relationFields as [string, ...string[]])) as typeof q;
359
416
  return q as unknown as Promise<Returning>;
360
417
  };
361
418
 
362
419
  const update = async (id: string, data: Values<Partial<Data>>) => {
363
- const merged = (await runBeforeHooks("beforeUpdate", prefix, data as Record<string, unknown>, {
364
- id,
365
- })) as Values<Partial<Data>>;
420
+ const merged = (await runBeforeHooks(
421
+ "beforeUpdate",
422
+ prefix,
423
+ data as Record<string, unknown>,
424
+ {
425
+ id,
426
+ },
427
+ )) as Values<Partial<Data>>;
366
428
 
367
429
  await assertUnique(merged, id);
368
430
 
@@ -381,7 +443,9 @@ export const makeModel = <Rules extends z.ZodObject>(
381
443
  const destroy = async (id: string) => {
382
444
  await runBeforeDestroyHooks(prefix, id);
383
445
 
384
- const record = (await (surreal().delete<Data>(new RecordId(table, id)) as unknown as Promise<Returning>)) as Returning;
446
+ const record = (await (surreal().delete<Data>(
447
+ new RecordId(table, id),
448
+ ) as unknown as Promise<Returning>)) as Returning;
385
449
 
386
450
  await runAfterHooks("afterDestroy", prefix, record);
387
451
 
@@ -402,10 +466,10 @@ export const makeModel = <Rules extends z.ZodObject>(
402
466
  };
403
467
  };
404
468
 
405
- export * from "./data_type";
406
- export * from "./hooks";
407
469
  export * from "../event";
408
470
  export * from "../helper";
409
471
  export * from "../plugin";
410
472
  export * from "../route";
411
473
  export * from "../vars";
474
+ export * from "./data_type";
475
+ export * from "./hooks";
@@ -3,14 +3,21 @@ import { basename, join, normalize, sep } from "node:path";
3
3
  // Resolves `requestedPath` against `baseDir` and rejects anything that escapes it
4
4
  // (e.g. "../../etc/passwd") — shared by every module that serves files straight
5
5
  // off disk based on a request path (uploads, admin panel SPA, etc).
6
- export const resolveSafePath = (baseDir: string, requestedPath: string): string | null => {
6
+ export const resolveSafePath = (
7
+ baseDir: string,
8
+ requestedPath: string,
9
+ ): string | null => {
7
10
  // A bare `startsWith` prefix check would let "/app/uploads-evil" pass for a
8
11
  // baseDir of "/app/uploads" — anchor the comparison to a directory boundary
9
12
  // instead so only baseDir itself (or a path inside it) matches.
10
13
  const normalizedBase = normalize(baseDir);
11
- const boundary = normalizedBase.endsWith(sep) ? normalizedBase : normalizedBase + sep;
14
+ const boundary = normalizedBase.endsWith(sep)
15
+ ? normalizedBase
16
+ : normalizedBase + sep;
12
17
  const resolved = normalize(join(baseDir, requestedPath));
13
- return resolved === normalizedBase || resolved.startsWith(boundary) ? resolved : null;
18
+ return resolved === normalizedBase || resolved.startsWith(boundary)
19
+ ? resolved
20
+ : null;
14
21
  };
15
22
 
16
23
  // Strips any directory component from a client-supplied filename (e.g. a multipart
@@ -1,4 +1,11 @@
1
- import type { EventFactory, HelperFactory, HookFactory, ModelFactory, RouteFactory, VarsFactory } from "./config";
1
+ import type {
2
+ EventFactory,
3
+ HelperFactory,
4
+ HookFactory,
5
+ ModelFactory,
6
+ RouteFactory,
7
+ VarsFactory,
8
+ } from "./config";
2
9
 
3
10
  export type Plugin = {
4
11
  models?: ModelFactory[];
@@ -0,0 +1,24 @@
1
+ import { getConfig } from "./config";
2
+
3
+ // The base URL clients actually reach the site on. config.publicUrl wins; otherwise it is
4
+ // derived from the request, honouring a reverse proxy's X-Forwarded-* headers — those are
5
+ // only trusted for building discovery documents, never for any access decision.
6
+ export const publicBase = (request: Request) => {
7
+ const configured = getConfig().publicUrl;
8
+ if (configured) {
9
+ // Not a /\/+$/ regex — that is quadratic on a long run of slashes.
10
+ let base = configured;
11
+ while (base.endsWith("/")) base = base.slice(0, -1);
12
+ return base;
13
+ }
14
+
15
+ const url = new URL(request.url);
16
+ const first = (name: string) =>
17
+ request.headers.get(name)?.split(",")[0]?.trim();
18
+
19
+ const proto = first("x-forwarded-proto") ?? url.protocol.replace(":", "");
20
+ const host =
21
+ first("x-forwarded-host") ?? request.headers.get("host") ?? url.host;
22
+
23
+ return `${proto}://${host}`;
24
+ };
package/src/core/route.ts CHANGED
@@ -3,4 +3,5 @@ import { Elysia } from "elysia";
3
3
  // Thin factory over Elysia itself — purely for DX (autocomplete/type-checking),
4
4
  // same purpose as defineConfig. Custom route files get the full Elysia API
5
5
  // (schema validation, guards, groups, macros, ...) with no wrapping layer.
6
- export const route = (config?: ConstructorParameters<typeof Elysia>[0]) => new Elysia(config);
6
+ export const route = (config?: ConstructorParameters<typeof Elysia>[0]) =>
7
+ new Elysia(config);
@@ -1,7 +1,7 @@
1
- import surreal from "../../modules/surreal";
2
1
  import moment from "moment";
3
2
  import { RecordId, Table, type Values } from "surrealdb";
4
- import { z } from "zod";
3
+ import type { z } from "zod";
4
+ import surreal from "../../modules/surreal";
5
5
  import { runAfterHooks, runBeforeHooks } from "../model/hooks";
6
6
 
7
7
  // One shared physical table for every VARS group — rows are keyed by each
@@ -26,9 +26,16 @@ export const makeVars = <Rules extends z.ZodObject>(
26
26
  // returns a complete, valid object. Every VARS field is expected to
27
27
  // declare a .default(...) for this reason.
28
28
  const get = async (): Promise<Returning> => {
29
- const record = await surreal().select<Data & { updatedAt?: string }>(recordId);
30
- const { updatedAt, ...rest } = (record ?? {}) as Record<string, unknown> & { updatedAt?: string };
31
- return { ...(config.schema.parse(rest) as Data), updatedAt: updatedAt ?? null };
29
+ const record = await surreal().select<Data & { updatedAt?: string }>(
30
+ recordId,
31
+ );
32
+ const { updatedAt, ...rest } = (record ?? {}) as Record<string, unknown> & {
33
+ updatedAt?: string;
34
+ };
35
+ return {
36
+ ...(config.schema.parse(rest) as Data),
37
+ updatedAt: updatedAt ?? null,
38
+ };
32
39
  };
33
40
 
34
41
  // Upsert, not update — the row may not exist yet on first save.
@@ -42,11 +49,18 @@ export const makeVars = <Rules extends z.ZodObject>(
42
49
 
43
50
  const record = (await surreal()
44
51
  .upsert<Data & { updatedAt: string }>(recordId)
45
- .merge({ ...merged, updatedAt: moment().toISOString() } as Values<Data & { updatedAt: string }>)
52
+ .merge({ ...merged, updatedAt: moment().toISOString() } as Values<
53
+ Data & { updatedAt: string }
54
+ >)
46
55
  .output("after")) as (Data & { updatedAt: string }) | undefined;
47
56
 
48
- const { updatedAt, ...rest } = (record ?? {}) as Record<string, unknown> & { updatedAt?: string };
49
- const result = { ...(config.schema.parse(rest) as Data), updatedAt: updatedAt ?? null };
57
+ const { updatedAt, ...rest } = (record ?? {}) as Record<string, unknown> & {
58
+ updatedAt?: string;
59
+ };
60
+ const result = {
61
+ ...(config.schema.parse(rest) as Data),
62
+ updatedAt: updatedAt ?? null,
63
+ };
50
64
 
51
65
  await runAfterHooks("afterVarsUpdate", prefix, result);
52
66
 
@@ -45,7 +45,10 @@ export type WorkerReadyMessage = {
45
45
  type: "ready";
46
46
  };
47
47
 
48
- export type WorkerOutboundMessage = WorkerResponseMessage | WorkerErrorMessage | WorkerReadyMessage;
48
+ export type WorkerOutboundMessage =
49
+ | WorkerResponseMessage
50
+ | WorkerErrorMessage
51
+ | WorkerReadyMessage;
49
52
 
50
53
  // Bounds how long shutdown waits for in-flight requests to finish before exiting anyway —
51
54
  // the supervisor can terminate a worker at any time, so this is a safety net, not the
@@ -54,14 +57,21 @@ const SHUTDOWN_DRAIN_TIMEOUT_MS = 10_000;
54
57
  const SHUTDOWN_POLL_INTERVAL_MS = 50;
55
58
 
56
59
  const isRequestMessage = (raw: unknown): raw is WorkerRequestMessage =>
57
- typeof raw === "object" && raw !== null && (raw as { type?: unknown }).type === "request";
60
+ typeof raw === "object" &&
61
+ raw !== null &&
62
+ (raw as { type?: unknown }).type === "request";
58
63
 
59
64
  const isShutdownMessage = (raw: unknown): raw is WorkerShutdownMessage =>
60
- typeof raw === "object" && raw !== null && (raw as { type?: unknown }).type === "shutdown";
65
+ typeof raw === "object" &&
66
+ raw !== null &&
67
+ (raw as { type?: unknown }).type === "shutdown";
61
68
 
62
69
  // Pure request/response conversion, split out from serveWorker()'s self/postMessage wiring
63
70
  // so it can be unit tested directly without spinning up a real Worker thread.
64
- export const buildResponseMessage = async (app: AnyElysia, msg: WorkerRequestMessage): Promise<WorkerResponseMessage> => {
71
+ export const buildResponseMessage = async (
72
+ app: AnyElysia,
73
+ msg: WorkerRequestMessage,
74
+ ): Promise<WorkerResponseMessage> => {
65
75
  const request = new Request(msg.url, {
66
76
  method: msg.method,
67
77
  headers: msg.headers,
@@ -90,7 +100,9 @@ export const buildResponseMessage = async (app: AnyElysia, msg: WorkerRequestMes
90
100
  // real socket connections; app.handle() is the same Elysia entrypoint either way.
91
101
  export const serveWorker = (app: AnyElysia): void => {
92
102
  if (Bun.isMainThread) {
93
- throw new Error('serveWorker() must run inside a Worker thread (new Worker("out/worker.js")), not the main thread.');
103
+ throw new Error(
104
+ 'serveWorker() must run inside a Worker thread (new Worker("out/worker.js")), not the main thread.',
105
+ );
94
106
  }
95
107
 
96
108
  let inFlight = 0;
@@ -98,7 +110,11 @@ export const serveWorker = (app: AnyElysia): void => {
98
110
 
99
111
  const handleRequest = async (msg: WorkerRequestMessage): Promise<void> => {
100
112
  if (shuttingDown) {
101
- self.postMessage({ type: "error", id: msg.id, message: "worker is shutting down" } satisfies WorkerErrorMessage);
113
+ self.postMessage({
114
+ type: "error",
115
+ id: msg.id,
116
+ message: "worker is shutting down",
117
+ } satisfies WorkerErrorMessage);
102
118
  return;
103
119
  }
104
120
 
@@ -1,6 +1,6 @@
1
+ import z from "zod";
1
2
  import { getConfig } from "../core/config";
2
3
  import { makeModel, numeric, text } from "../core/model";
3
- import z from "zod";
4
4
 
5
5
  const file = makeModel("file", {
6
6
  name: "File",
@@ -1,5 +1,5 @@
1
- import { email, makeModel, text } from "../core/model";
2
1
  import z from "zod";
2
+ import { email, makeModel, text } from "../core/model";
3
3
 
4
4
  const user = makeModel("user", {
5
5
  name: "Pengguna",
@@ -1,8 +1,8 @@
1
+ import { join } from "node:path";
2
+ import Elysia from "elysia";
1
3
  import { getConfig } from "../core/config";
2
4
  import { makeModule } from "../core/module";
3
5
  import { resolveSafePath } from "../core/path_guard";
4
- import Elysia from "elysia";
5
- import { join } from "node:path";
6
6
  import elysia from "./elysia";
7
7
 
8
8
  // Lazy, not a top-level const — this file's static import runs before module.ts's own
@@ -1,9 +1,9 @@
1
- import { makeModule } from "../core/module";
2
1
  import Elysia from "elysia";
3
2
  import geoip from "geoip-lite";
4
3
  import moment from "moment";
5
4
  import { Table } from "surrealdb";
6
5
  import z from "zod";
6
+ import { makeModule } from "../core/module";
7
7
  import auth from "./auth";
8
8
  import elysia from "./elysia";
9
9
  import logger from "./logger";
@@ -46,9 +46,27 @@ export const resolveClientIp = (
46
46
  return server?.requestIP(request)?.address ?? null;
47
47
  };
48
48
 
49
+ // `request.url` reflects what the backend itself sees — behind a reverse proxy that
50
+ // terminates TLS (the common case), that's `http://` even though visitors hit the site
51
+ // over `https://`. That scheme mismatch alone breaks isSameOrigin's exact comparison,
52
+ // so every internal navigation gets misclassified as an external referrer. Prefer the
53
+ // headers a trusted proxy sets; fall back to request.url for direct connections.
54
+ export const resolveRequestOrigin = (request: Request) => {
55
+ const forwardedHost = request.headers.get("x-forwarded-host");
56
+ if (forwardedHost) {
57
+ const proto =
58
+ request.headers.get("x-forwarded-proto")?.split(",")[0]!.trim() || "http";
59
+ return `${proto}://${forwardedHost.split(",")[0]!.trim()}`;
60
+ }
61
+
62
+ return new URL(request.url).origin;
63
+ };
64
+
49
65
  const hashVisitor = (ip: string, userAgent: string) => {
50
66
  const today = moment().format("YYYY-MM-DD");
51
- return new Bun.CryptoHasher("sha256").update(`${ip}:${userAgent}:${today}`).digest("hex");
67
+ return new Bun.CryptoHasher("sha256")
68
+ .update(`${ip}:${userAgent}:${today}`)
69
+ .digest("hex");
52
70
  };
53
71
 
54
72
  const lookupCountry = (ip: string | null) => {
@@ -87,22 +105,28 @@ const buildWhere = (from?: string, to?: string, path?: string) => {
87
105
 
88
106
  const analytics = makeModule(() => {
89
107
  const fn = () => ({
90
- track: async ({ path, referrer, userAgent, ip, requestUrl }: TrackInput) => {
108
+ track: async ({
109
+ path,
110
+ referrer,
111
+ userAgent,
112
+ ip,
113
+ requestUrl,
114
+ }: TrackInput) => {
91
115
  try {
92
116
  const country = lookupCountry(ip);
93
117
  const visitorHash = ip && userAgent ? hashVisitor(ip, userAgent) : null;
94
- const externalReferrer = isSameOrigin(referrer, requestUrl) ? null : referrer;
95
-
96
- await surreal()
97
- .create(table)
98
- .content({
99
- path,
100
- referrer: externalReferrer,
101
- userAgent,
102
- country,
103
- visitorHash,
104
- createdAt: moment().toISOString(),
105
- });
118
+ const externalReferrer = isSameOrigin(referrer, requestUrl)
119
+ ? null
120
+ : referrer;
121
+
122
+ await surreal().create(table).content({
123
+ path,
124
+ referrer: externalReferrer,
125
+ userAgent,
126
+ country,
127
+ visitorHash,
128
+ createdAt: moment().toISOString(),
129
+ });
106
130
  } catch (e) {
107
131
  logger().error(e, "[analytics] failed to track pageview");
108
132
  }
@@ -118,13 +142,14 @@ const analytics = makeModule(() => {
118
142
  "/summary",
119
143
  async ({ query: { from, to, path } }) => {
120
144
  const { where, params } = buildWhere(from, to, path);
121
- const [rows] = await surreal().query<[{ visitorHash: string | null }[]]>(
122
- `SELECT visitorHash FROM pageview ${where}`,
123
- params,
124
- );
145
+ const [rows] = await surreal().query<
146
+ [{ visitorHash: string | null }[]]
147
+ >(`SELECT visitorHash FROM pageview ${where}`, params);
125
148
 
126
149
  const totalPageviews = rows?.length ?? 0;
127
- const uniqueVisitors = new Set((rows ?? []).map((r) => r.visitorHash).filter(Boolean)).size;
150
+ const uniqueVisitors = new Set(
151
+ (rows ?? []).map((r) => r.visitorHash).filter(Boolean),
152
+ ).size;
128
153
 
129
154
  return { data: { totalPageviews, uniqueVisitors } };
130
155
  },
@@ -134,7 +159,9 @@ const analytics = makeModule(() => {
134
159
  "/by-country",
135
160
  async ({ query: { from, to, path } }) => {
136
161
  const { where, params } = buildWhere(from, to, path);
137
- const [rows] = await surreal().query<[{ country: string | null; count: number }[]]>(
162
+ const [rows] = await surreal().query<
163
+ [{ country: string | null; count: number }[]]
164
+ >(
138
165
  `SELECT country, count() AS count FROM pageview ${where} GROUP BY country ORDER BY count DESC`,
139
166
  params,
140
167
  );
@@ -147,7 +174,9 @@ const analytics = makeModule(() => {
147
174
  "/by-referrer",
148
175
  async ({ query: { from, to, path } }) => {
149
176
  const { where, params } = buildWhere(from, to, path);
150
- const [rows] = await surreal().query<[{ referrer: string | null; count: number }[]]>(
177
+ const [rows] = await surreal().query<
178
+ [{ referrer: string | null; count: number }[]]
179
+ >(
151
180
  `SELECT referrer, count() AS count FROM pageview ${where} GROUP BY referrer ORDER BY count DESC`,
152
181
  params,
153
182
  );
@@ -160,7 +189,9 @@ const analytics = makeModule(() => {
160
189
  "/by-page",
161
190
  async ({ query: { from, to } }) => {
162
191
  const { where, params } = buildWhere(from, to, undefined);
163
- const [rows] = await surreal().query<[{ path: string; count: number }[]]>(
192
+ const [rows] = await surreal().query<
193
+ [{ path: string; count: number }[]]
194
+ >(
164
195
  `SELECT path, count() AS count FROM pageview ${where} GROUP BY path ORDER BY count DESC`,
165
196
  params,
166
197
  );
@@ -178,7 +209,9 @@ const analytics = makeModule(() => {
178
209
  const { where, params } = buildWhere(from, to, path);
179
210
  const bucketLen = interval === "hour" ? 13 : 10; // "YYYY-MM-DDTHH" vs "YYYY-MM-DD"
180
211
 
181
- const [rows] = await surreal().query<[{ date: string; count: number }[]]>(
212
+ const [rows] = await surreal().query<
213
+ [{ date: string; count: number }[]]
214
+ >(
182
215
  `SELECT string::slice(createdAt, 0, ${bucketLen}) AS date, count() AS count
183
216
  FROM pageview ${where}
184
217
  GROUP BY date
@@ -201,7 +234,9 @@ const analytics = makeModule(() => {
201
234
  return {
202
235
  // Runs after surreal's own spin() has connected — init() itself runs too early for DB queries.
203
236
  spin: async () => {
204
- await surreal().query("DEFINE TABLE IF NOT EXISTS pageview SCHEMALESS;");
237
+ await surreal().query(
238
+ "DEFINE TABLE IF NOT EXISTS pageview SCHEMALESS;",
239
+ );
205
240
  },
206
241
  };
207
242
  };