tina4-nodejs 3.13.36 → 3.13.38
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.
- package/CLAUDE.md +51 -19
- package/package.json +5 -3
- package/packages/cli/src/bin.ts +7 -0
- package/packages/cli/src/commands/init.ts +1 -0
- package/packages/cli/src/commands/metrics.ts +154 -0
- package/packages/cli/src/commands/routes.ts +3 -3
- package/packages/core/public/js/tina4-dev-admin.js +212 -212
- package/packages/core/public/js/tina4-dev-admin.min.js +212 -212
- package/packages/core/src/auth.ts +112 -2
- package/packages/core/src/cache.ts +2 -2
- package/packages/core/src/devAdmin.ts +75 -26
- package/packages/core/src/devMailbox.ts +4 -0
- package/packages/core/src/dotenv.ts +13 -4
- package/packages/core/src/events.ts +86 -4
- package/packages/core/src/graphql.ts +182 -128
- package/packages/core/src/htmlElement.ts +62 -3
- package/packages/core/src/index.ts +14 -8
- package/packages/core/src/logger.ts +1 -1
- package/packages/core/src/mcp.test.ts +1 -1
- package/packages/core/src/messenger.ts +111 -11
- package/packages/core/src/metrics.ts +232 -33
- package/packages/core/src/middleware.ts +129 -39
- package/packages/core/src/plan.ts +1 -1
- package/packages/core/src/queue.ts +1 -1
- package/packages/core/src/queueBackends/kafkaBackend.ts +1 -1
- package/packages/core/src/queueBackends/mongoBackend.ts +1 -1
- package/packages/core/src/queueBackends/rabbitmqBackend.ts +1 -1
- package/packages/core/src/rateLimiter.ts +1 -1
- package/packages/core/src/response.ts +90 -6
- package/packages/core/src/router.ts +2 -2
- package/packages/core/src/server.ts +26 -4
- package/packages/core/src/session.ts +130 -18
- package/packages/core/src/sessionHandlers/databaseHandler.ts +10 -0
- package/packages/core/src/sessionHandlers/mongoHandler.ts +21 -4
- package/packages/core/src/sessionHandlers/redisHandler.ts +28 -7
- package/packages/core/src/sessionHandlers/valkeyHandler.ts +27 -8
- package/packages/core/src/testClient.ts +1 -1
- package/packages/core/src/websocket.ts +247 -33
- package/packages/core/src/websocketBackplane.ts +210 -10
- package/packages/core/src/wsdl.ts +55 -21
- package/packages/orm/src/adapters/pg-types.d.ts +60 -0
- package/packages/orm/src/adapters/postgres.ts +26 -4
- package/packages/orm/src/adapters/sqlite.ts +112 -13
- package/packages/orm/src/baseModel.ts +8 -3
- package/packages/orm/src/cachedDatabase.ts +15 -6
- package/packages/orm/src/database.ts +257 -55
- package/packages/orm/src/index.ts +2 -1
- package/packages/orm/src/migration.ts +2 -2
- package/packages/orm/src/seeder.ts +443 -65
- package/packages/swagger/src/ui.ts +1 -1
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
* - Error capture (resolver exceptions become GraphQL errors)
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
+
import { Log } from "./logger.js";
|
|
24
|
+
import { isDebugMode } from "./errorOverlay.js";
|
|
25
|
+
|
|
23
26
|
// ── Types ────────────────────────────────────────────────────
|
|
24
27
|
|
|
25
28
|
export interface GraphQLField {
|
|
@@ -115,6 +118,19 @@ interface ParsedField {
|
|
|
115
118
|
selections: ParsedSelection[] | null;
|
|
116
119
|
}
|
|
117
120
|
|
|
121
|
+
interface ParsedFragmentSpread {
|
|
122
|
+
kind: "fragment_spread";
|
|
123
|
+
name: string;
|
|
124
|
+
directives: ParsedDirective[];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface ParsedInlineFragment {
|
|
128
|
+
kind: "inline_fragment";
|
|
129
|
+
on: string | null;
|
|
130
|
+
directives: ParsedDirective[];
|
|
131
|
+
selections: ParsedSelection[];
|
|
132
|
+
}
|
|
133
|
+
|
|
118
134
|
interface ParsedDirective {
|
|
119
135
|
name: string;
|
|
120
136
|
args: Record<string, unknown>;
|
|
@@ -129,13 +145,22 @@ interface ParsedOperation {
|
|
|
129
145
|
selections: ParsedSelection[];
|
|
130
146
|
}
|
|
131
147
|
|
|
148
|
+
interface ParsedFragment {
|
|
149
|
+
kind: "fragment";
|
|
150
|
+
name: string;
|
|
151
|
+
on: string;
|
|
152
|
+
directives: ParsedDirective[];
|
|
153
|
+
selections: ParsedSelection[];
|
|
154
|
+
}
|
|
155
|
+
|
|
132
156
|
interface ParsedVariableDef {
|
|
133
157
|
name: string;
|
|
134
158
|
type: string;
|
|
135
159
|
default: unknown;
|
|
136
160
|
}
|
|
137
161
|
|
|
138
|
-
type ParsedSelection = ParsedField;
|
|
162
|
+
type ParsedSelection = ParsedField | ParsedFragmentSpread | ParsedInlineFragment;
|
|
163
|
+
type ParsedDefinition = ParsedOperation | ParsedFragment;
|
|
139
164
|
|
|
140
165
|
class Parser {
|
|
141
166
|
private tokens: Token[];
|
|
@@ -174,14 +199,38 @@ class Parser {
|
|
|
174
199
|
return null;
|
|
175
200
|
}
|
|
176
201
|
|
|
177
|
-
parse(): { definitions:
|
|
178
|
-
const doc: { definitions:
|
|
202
|
+
parse(): { definitions: ParsedDefinition[] } {
|
|
203
|
+
const doc: { definitions: ParsedDefinition[] } = { definitions: [] };
|
|
179
204
|
while (this.pos < this.tokens.length) {
|
|
180
|
-
doc.definitions.push(this.
|
|
205
|
+
doc.definitions.push(this.parseDefinition());
|
|
181
206
|
}
|
|
182
207
|
return doc;
|
|
183
208
|
}
|
|
184
209
|
|
|
210
|
+
private parseDefinition(): ParsedDefinition {
|
|
211
|
+
const t = this.peek();
|
|
212
|
+
if (t && t.type === "NAME" && t.value === "fragment") {
|
|
213
|
+
return this.parseFragment();
|
|
214
|
+
}
|
|
215
|
+
return this.parseOperation();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private parseFragment(): ParsedFragment {
|
|
219
|
+
this.expect("NAME", "fragment");
|
|
220
|
+
const name = this.expect("NAME").value;
|
|
221
|
+
this.expect("NAME", "on");
|
|
222
|
+
const typeName = this.expect("NAME").value;
|
|
223
|
+
const directives = this.parseDirectives();
|
|
224
|
+
const selections = this.parseSelectionSet();
|
|
225
|
+
return {
|
|
226
|
+
kind: "fragment",
|
|
227
|
+
name,
|
|
228
|
+
on: typeName,
|
|
229
|
+
directives,
|
|
230
|
+
selections,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
185
234
|
private parseOperation(): ParsedOperation {
|
|
186
235
|
const t = this.peek();
|
|
187
236
|
let opType = "query";
|
|
@@ -216,7 +265,29 @@ class Parser {
|
|
|
216
265
|
this.expect("LBRACE");
|
|
217
266
|
const selections: ParsedSelection[] = [];
|
|
218
267
|
while (!this.match("RBRACE")) {
|
|
219
|
-
|
|
268
|
+
if (this.match("SPREAD")) {
|
|
269
|
+
const next = this.peek();
|
|
270
|
+
if (next && next.type === "NAME" && next.value === "on") {
|
|
271
|
+
// Inline fragment: ... on Type { ... }
|
|
272
|
+
this.advance();
|
|
273
|
+
const typeName = this.expect("NAME").value;
|
|
274
|
+
const directives = this.parseDirectives();
|
|
275
|
+
const sels = this.parseSelectionSet();
|
|
276
|
+
selections.push({ kind: "inline_fragment", on: typeName, directives, selections: sels });
|
|
277
|
+
} else if (next && next.type === "LBRACE") {
|
|
278
|
+
// Inline fragment without type condition: ... { ... }
|
|
279
|
+
const directives = this.parseDirectives();
|
|
280
|
+
const sels = this.parseSelectionSet();
|
|
281
|
+
selections.push({ kind: "inline_fragment", on: null, directives, selections: sels });
|
|
282
|
+
} else {
|
|
283
|
+
// Fragment spread: ...FragmentName
|
|
284
|
+
const name = this.expect("NAME").value;
|
|
285
|
+
const directives = this.parseDirectives();
|
|
286
|
+
selections.push({ kind: "fragment_spread", name, directives });
|
|
287
|
+
}
|
|
288
|
+
} else {
|
|
289
|
+
selections.push(this.parseField());
|
|
290
|
+
}
|
|
220
291
|
}
|
|
221
292
|
return selections;
|
|
222
293
|
}
|
|
@@ -397,6 +468,18 @@ export function graphqlAutoSchemaEnabled(): boolean {
|
|
|
397
468
|
return ["true", "1", "yes", "on"].includes(raw);
|
|
398
469
|
}
|
|
399
470
|
|
|
471
|
+
/**
|
|
472
|
+
* Maximum selection-set nesting depth. A deeply nested query (or a circular
|
|
473
|
+
* fragment) would otherwise recurse without bound — a classic GraphQL DoS /
|
|
474
|
+
* stack-overflow vector. `TINA4_GRAPHQL_MAX_DEPTH` overrides the default (50);
|
|
475
|
+
* set `<= 0` to disable the guard. A non-numeric value falls back to 50.
|
|
476
|
+
*/
|
|
477
|
+
export function graphqlMaxDepth(): number {
|
|
478
|
+
const raw = (process.env.TINA4_GRAPHQL_MAX_DEPTH ?? "50").trim();
|
|
479
|
+
const n = parseInt(raw, 10);
|
|
480
|
+
return Number.isNaN(n) ? 50 : n;
|
|
481
|
+
}
|
|
482
|
+
|
|
400
483
|
// ── GraphQL Engine ───────────────────────────────────────────
|
|
401
484
|
|
|
402
485
|
export class GraphQL {
|
|
@@ -416,6 +499,14 @@ export class GraphQL {
|
|
|
416
499
|
private static classResolvers = new Map<string, Map<string, ResolverFn>>();
|
|
417
500
|
private static defaultInstance: GraphQL | null = null;
|
|
418
501
|
|
|
502
|
+
/**
|
|
503
|
+
* Maximum selection-set nesting depth (read from `TINA4_GRAPHQL_MAX_DEPTH`,
|
|
504
|
+
* default 50; `<= 0` disables the guard). Public so tests can set it and the
|
|
505
|
+
* dev app can introspect it. Counted per selection level AND per fragment
|
|
506
|
+
* spread / inline fragment, so circular fragments are caught too.
|
|
507
|
+
*/
|
|
508
|
+
public maxDepth: number = graphqlMaxDepth();
|
|
509
|
+
|
|
419
510
|
/**
|
|
420
511
|
* Decorator-style resolver registration.
|
|
421
512
|
*
|
|
@@ -527,21 +618,6 @@ export class GraphQL {
|
|
|
527
618
|
return this;
|
|
528
619
|
}
|
|
529
620
|
|
|
530
|
-
/**
|
|
531
|
-
* Return schema metadata for debugging.
|
|
532
|
-
*/
|
|
533
|
-
introspect(): Record<string, unknown> {
|
|
534
|
-
const queries: Record<string, unknown> = {};
|
|
535
|
-
for (const [name, config] of Object.entries(this.queries)) {
|
|
536
|
-
queries[name] = { type: (config as any).type, args: (config as any).args ?? {} };
|
|
537
|
-
}
|
|
538
|
-
const mutations: Record<string, unknown> = {};
|
|
539
|
-
for (const [name, config] of Object.entries(this.mutations)) {
|
|
540
|
-
mutations[name] = { type: (config as any).type, args: (config as any).args ?? {} };
|
|
541
|
-
}
|
|
542
|
-
return { types: Object.keys(this.types), queries, mutations };
|
|
543
|
-
}
|
|
544
|
-
|
|
545
621
|
/**
|
|
546
622
|
* Register a query resolver.
|
|
547
623
|
*/
|
|
@@ -555,21 +631,6 @@ export class GraphQL {
|
|
|
555
631
|
return this;
|
|
556
632
|
}
|
|
557
633
|
|
|
558
|
-
/**
|
|
559
|
-
* Return schema metadata for debugging.
|
|
560
|
-
*/
|
|
561
|
-
introspect(): Record<string, unknown> {
|
|
562
|
-
const queries: Record<string, unknown> = {};
|
|
563
|
-
for (const [name, config] of Object.entries(this.queries)) {
|
|
564
|
-
queries[name] = { type: (config as any).type, args: (config as any).args ?? {} };
|
|
565
|
-
}
|
|
566
|
-
const mutations: Record<string, unknown> = {};
|
|
567
|
-
for (const [name, config] of Object.entries(this.mutations)) {
|
|
568
|
-
mutations[name] = { type: (config as any).type, args: (config as any).args ?? {} };
|
|
569
|
-
}
|
|
570
|
-
return { types: Object.keys(this.types), queries, mutations };
|
|
571
|
-
}
|
|
572
|
-
|
|
573
634
|
/**
|
|
574
635
|
* Register a mutation resolver.
|
|
575
636
|
*/
|
|
@@ -583,21 +644,6 @@ export class GraphQL {
|
|
|
583
644
|
return this;
|
|
584
645
|
}
|
|
585
646
|
|
|
586
|
-
/**
|
|
587
|
-
* Return schema metadata for debugging.
|
|
588
|
-
*/
|
|
589
|
-
introspect(): Record<string, unknown> {
|
|
590
|
-
const queries: Record<string, unknown> = {};
|
|
591
|
-
for (const [name, config] of Object.entries(this.queries)) {
|
|
592
|
-
queries[name] = { type: (config as any).type, args: (config as any).args ?? {} };
|
|
593
|
-
}
|
|
594
|
-
const mutations: Record<string, unknown> = {};
|
|
595
|
-
for (const [name, config] of Object.entries(this.mutations)) {
|
|
596
|
-
mutations[name] = { type: (config as any).type, args: (config as any).args ?? {} };
|
|
597
|
-
}
|
|
598
|
-
return { types: Object.keys(this.types), queries, mutations };
|
|
599
|
-
}
|
|
600
|
-
|
|
601
647
|
/**
|
|
602
648
|
* Execute a GraphQL query string.
|
|
603
649
|
*/
|
|
@@ -606,7 +652,7 @@ export class GraphQL {
|
|
|
606
652
|
const ctx = context ?? {};
|
|
607
653
|
const errors: Array<{ message: string; path?: string[] }> = [];
|
|
608
654
|
|
|
609
|
-
let doc: { definitions:
|
|
655
|
+
let doc: { definitions: ParsedDefinition[] };
|
|
610
656
|
try {
|
|
611
657
|
const tokens = tokenize(query);
|
|
612
658
|
const parser = new Parser(tokens);
|
|
@@ -616,11 +662,23 @@ export class GraphQL {
|
|
|
616
662
|
return { data: null, errors: [{ message }] };
|
|
617
663
|
}
|
|
618
664
|
|
|
619
|
-
|
|
665
|
+
// Split definitions into operations and fragments (fragments are named
|
|
666
|
+
// and referenced by spreads, so collect them first).
|
|
667
|
+
const fragments: Map<string, ParsedFragment> = new Map();
|
|
668
|
+
const operations: ParsedOperation[] = [];
|
|
669
|
+
for (const defn of doc.definitions) {
|
|
670
|
+
if (defn.kind === "fragment") {
|
|
671
|
+
fragments.set(defn.name, defn);
|
|
672
|
+
} else {
|
|
673
|
+
operations.push(defn);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if (operations.length === 0) {
|
|
620
678
|
return { data: null, errors: [{ message: "No operation found" }] };
|
|
621
679
|
}
|
|
622
680
|
|
|
623
|
-
const op =
|
|
681
|
+
const op = operations[0];
|
|
624
682
|
const resolvers = op.operation === "query" ? this.queries : this.mutations;
|
|
625
683
|
|
|
626
684
|
// Apply variable defaults
|
|
@@ -631,16 +689,9 @@ export class GraphQL {
|
|
|
631
689
|
}
|
|
632
690
|
|
|
633
691
|
const data: Record<string, unknown> = {};
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
if (!this.checkDirectives(sel.directives ?? [], vars, ctx)) continue;
|
|
638
|
-
|
|
639
|
-
const [value, errs] = this.resolveField(sel, resolvers, null, vars, ctx);
|
|
640
|
-
errors.push(...errs);
|
|
641
|
-
const key = sel.alias ?? sel.name;
|
|
642
|
-
data[key] = value;
|
|
643
|
-
}
|
|
692
|
+
// Top-level selections start at depth 1.
|
|
693
|
+
const errs = this.resolveSelectionsInto(op.selections, resolvers, null, vars, ctx, fragments, data, 1);
|
|
694
|
+
errors.push(...errs);
|
|
644
695
|
|
|
645
696
|
const result: GraphQLResult = { data };
|
|
646
697
|
if (errors.length > 0) {
|
|
@@ -650,55 +701,68 @@ export class GraphQL {
|
|
|
650
701
|
}
|
|
651
702
|
|
|
652
703
|
/**
|
|
653
|
-
*
|
|
704
|
+
* Resolve a list of selections and merge results into the `target` dict.
|
|
705
|
+
* Fragment spreads and inline fragments are merged (not nested).
|
|
706
|
+
*
|
|
707
|
+
* `depth` is incremented on every recursive entry (field sub-selections,
|
|
708
|
+
* fragment spreads, inline fragments) and checked against `maxDepth` so an
|
|
709
|
+
* over-deep query or a circular fragment fails with a structured error
|
|
710
|
+
* instead of recursing until the interpreter stack overflows. Top-level
|
|
711
|
+
* starts at depth 1; `maxDepth <= 0` disables the guard.
|
|
654
712
|
*/
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
713
|
+
private resolveSelectionsInto(
|
|
714
|
+
selections: ParsedSelection[],
|
|
715
|
+
resolvers: Map<string, QueryConfig>,
|
|
716
|
+
parent: unknown,
|
|
717
|
+
variables: Record<string, unknown>,
|
|
718
|
+
context: Record<string, unknown>,
|
|
719
|
+
fragments: Map<string, ParsedFragment>,
|
|
720
|
+
target: Record<string, unknown>,
|
|
721
|
+
depth: number,
|
|
722
|
+
): Array<{ message: string; path?: string[] }> {
|
|
723
|
+
const errors: Array<{ message: string; path?: string[] }> = [];
|
|
724
|
+
|
|
725
|
+
if (this.maxDepth > 0 && depth > this.maxDepth) {
|
|
726
|
+
return [{ message: `Query exceeds maximum depth of ${this.maxDepth}` }];
|
|
663
727
|
}
|
|
664
|
-
return { types: Object.keys(this.types), queries, mutations };
|
|
665
|
-
}
|
|
666
728
|
|
|
729
|
+
for (const sel of selections) {
|
|
730
|
+
// Check directives (@skip, @include, @auth, @role, @guest)
|
|
731
|
+
if (!this.checkDirectives(sel.directives ?? [], variables, context)) continue;
|
|
667
732
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
733
|
+
if (sel.kind === "fragment_spread") {
|
|
734
|
+
const frag = fragments.get(sel.name);
|
|
735
|
+
if (!frag) {
|
|
736
|
+
errors.push({ message: `Fragment not found: ${sel.name}` });
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
const errs = this.resolveSelectionsInto(
|
|
740
|
+
frag.selections, resolvers, parent, variables, context, fragments, target, depth + 1,
|
|
741
|
+
);
|
|
742
|
+
errors.push(...errs);
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
if (sel.kind === "inline_fragment") {
|
|
747
|
+
const errs = this.resolveSelectionsInto(
|
|
748
|
+
sel.selections, resolvers, parent, variables, context, fragments, target, depth + 1,
|
|
749
|
+
);
|
|
750
|
+
errors.push(...errs);
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const [value, errs] = this.resolveField(sel, resolvers, parent, variables, context, fragments, depth);
|
|
755
|
+
errors.push(...errs);
|
|
756
|
+
const key = sel.alias ?? sel.name;
|
|
757
|
+
target[key] = value;
|
|
679
758
|
}
|
|
680
|
-
|
|
759
|
+
|
|
760
|
+
return errors;
|
|
681
761
|
}
|
|
682
762
|
|
|
683
763
|
/**
|
|
684
764
|
* Generate SDL schema string.
|
|
685
765
|
*/
|
|
686
|
-
|
|
687
|
-
/**
|
|
688
|
-
* Return schema metadata for debugging.
|
|
689
|
-
*/
|
|
690
|
-
introspect(): Record<string, unknown> {
|
|
691
|
-
const queries: Record<string, unknown> = {};
|
|
692
|
-
for (const [name, config] of Object.entries(this.queries)) {
|
|
693
|
-
queries[name] = { type: (config as any).type, args: (config as any).args ?? {} };
|
|
694
|
-
}
|
|
695
|
-
const mutations: Record<string, unknown> = {};
|
|
696
|
-
for (const [name, config] of Object.entries(this.mutations)) {
|
|
697
|
-
mutations[name] = { type: (config as any).type, args: (config as any).args ?? {} };
|
|
698
|
-
}
|
|
699
|
-
return { types: Object.keys(this.types), queries, mutations };
|
|
700
|
-
}
|
|
701
|
-
|
|
702
766
|
schemaSdl(): string {
|
|
703
767
|
const lines: string[] = [];
|
|
704
768
|
|
|
@@ -737,21 +801,6 @@ export class GraphQL {
|
|
|
737
801
|
return lines.join("\n");
|
|
738
802
|
}
|
|
739
803
|
|
|
740
|
-
/**
|
|
741
|
-
* Return schema metadata for debugging.
|
|
742
|
-
*/
|
|
743
|
-
introspect(): Record<string, unknown> {
|
|
744
|
-
const queries: Record<string, unknown> = {};
|
|
745
|
-
for (const [name, config] of Object.entries(this.queries)) {
|
|
746
|
-
queries[name] = { type: (config as any).type, args: (config as any).args ?? {} };
|
|
747
|
-
}
|
|
748
|
-
const mutations: Record<string, unknown> = {};
|
|
749
|
-
for (const [name, config] of Object.entries(this.mutations)) {
|
|
750
|
-
mutations[name] = { type: (config as any).type, args: (config as any).args ?? {} };
|
|
751
|
-
}
|
|
752
|
-
return { types: Object.keys(this.types), queries, mutations };
|
|
753
|
-
}
|
|
754
|
-
|
|
755
804
|
/**
|
|
756
805
|
* Auto-generate type, queries, and CRUD mutations from an ORM model class.
|
|
757
806
|
*
|
|
@@ -967,6 +1016,8 @@ export class GraphQL {
|
|
|
967
1016
|
parent: unknown,
|
|
968
1017
|
variables: Record<string, unknown>,
|
|
969
1018
|
context: Record<string, unknown> = {},
|
|
1019
|
+
fragments: Map<string, ParsedFragment> = new Map(),
|
|
1020
|
+
depth: number = 1,
|
|
970
1021
|
): [unknown, Array<{ message: string; path?: string[] }>] {
|
|
971
1022
|
const errors: Array<{ message: string; path?: string[] }> = [];
|
|
972
1023
|
const name = sel.name;
|
|
@@ -997,8 +1048,13 @@ export class GraphQL {
|
|
|
997
1048
|
try {
|
|
998
1049
|
value = config.resolver(null, args, ctx);
|
|
999
1050
|
} catch (e: unknown) {
|
|
1051
|
+
// Log the real cause; only surface the detail to the client in debug
|
|
1052
|
+
// mode — a resolver exception can carry internal state (DB errors,
|
|
1053
|
+
// credentials) that must not leak. The path is always preserved.
|
|
1000
1054
|
const message = e instanceof Error ? e.message : String(e);
|
|
1001
|
-
|
|
1055
|
+
Log.error(`GraphQL resolver '${name}' failed: ${message}`);
|
|
1056
|
+
const detail = isDebugMode() ? message : "Internal server error";
|
|
1057
|
+
errors.push({ message: detail, path: [name] });
|
|
1002
1058
|
return [null, errors];
|
|
1003
1059
|
}
|
|
1004
1060
|
}
|
|
@@ -1011,11 +1067,10 @@ export class GraphQL {
|
|
|
1011
1067
|
const result: Record<string, unknown>[] = [];
|
|
1012
1068
|
for (const item of value) {
|
|
1013
1069
|
const obj: Record<string, unknown> = {};
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
}
|
|
1070
|
+
const errs = this.resolveSelectionsInto(
|
|
1071
|
+
sel.selections, new Map(), item, variables, context, fragments, obj, depth + 1,
|
|
1072
|
+
);
|
|
1073
|
+
errors.push(...errs);
|
|
1019
1074
|
result.push(obj);
|
|
1020
1075
|
}
|
|
1021
1076
|
return [result, errors];
|
|
@@ -1023,11 +1078,10 @@ export class GraphQL {
|
|
|
1023
1078
|
|
|
1024
1079
|
if (value !== null && value !== undefined) {
|
|
1025
1080
|
const obj: Record<string, unknown> = {};
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
}
|
|
1081
|
+
const errs = this.resolveSelectionsInto(
|
|
1082
|
+
sel.selections, new Map(), value, variables, context, fragments, obj, depth + 1,
|
|
1083
|
+
);
|
|
1084
|
+
errors.push(...errs);
|
|
1031
1085
|
return [obj, errors];
|
|
1032
1086
|
}
|
|
1033
1087
|
|
|
@@ -45,8 +45,35 @@ const HTML_TAGS: readonly string[] = [
|
|
|
45
45
|
"wbr",
|
|
46
46
|
];
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Raw — marker for trusted, pre-sanitised HTML that must render UNESCAPED.
|
|
50
|
+
*
|
|
51
|
+
* String/scalar children of an HtmlElement are HTML-escaped by default to
|
|
52
|
+
* prevent stored/reflected XSS. Wrap a value in Raw to opt out of escaping
|
|
53
|
+
* when (and only when) you have already sanitised it yourself.
|
|
54
|
+
*
|
|
55
|
+
* new HtmlElement("div", {}, ["<b>x</b>"]).toString() // <b>x</b> (escaped)
|
|
56
|
+
* new HtmlElement("div", {}, [new Raw("<b>x</b>")]).toString() // <b>x</b> (raw)
|
|
57
|
+
*
|
|
58
|
+
* Alias: SafeString.
|
|
59
|
+
*/
|
|
60
|
+
export class Raw {
|
|
61
|
+
readonly value: string;
|
|
62
|
+
|
|
63
|
+
constructor(value: string) {
|
|
64
|
+
this.value = String(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
toString(): string {
|
|
68
|
+
return this.value;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Alias — some callers/frameworks prefer the SafeString name (matches Frond/Python).
|
|
73
|
+
export const SafeString = Raw;
|
|
74
|
+
|
|
48
75
|
type Attrs = Record<string, string | number | boolean | null | undefined>;
|
|
49
|
-
type Child = string | number | HtmlElement;
|
|
76
|
+
type Child = string | number | HtmlElement | Raw;
|
|
50
77
|
|
|
51
78
|
function escapeAttr(value: string): string {
|
|
52
79
|
return value
|
|
@@ -56,8 +83,28 @@ function escapeAttr(value: string): string {
|
|
|
56
83
|
.replace(/>/g, ">");
|
|
57
84
|
}
|
|
58
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Escape a plain string/scalar child so it cannot inject markup (defeats XSS).
|
|
88
|
+
*/
|
|
89
|
+
function escapeText(value: string): string {
|
|
90
|
+
return value
|
|
91
|
+
.replace(/&/g, "&")
|
|
92
|
+
.replace(/</g, "<")
|
|
93
|
+
.replace(/>/g, ">");
|
|
94
|
+
}
|
|
95
|
+
|
|
59
96
|
function isAttrs(arg: unknown): arg is Attrs {
|
|
60
|
-
return typeof arg === "object" && arg !== null
|
|
97
|
+
return typeof arg === "object" && arg !== null
|
|
98
|
+
&& !(arg instanceof HtmlElement) && !(arg instanceof Raw) && !Array.isArray(arg);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A builder produced by htmlElement() is a callable function branded with
|
|
103
|
+
* `_isHtmlElement`. Its toString() already produces fully-escaped HTML, so it
|
|
104
|
+
* must render verbatim (not be escaped as a string).
|
|
105
|
+
*/
|
|
106
|
+
function isBuilderElement(arg: unknown): boolean {
|
|
107
|
+
return typeof arg === "function" && (arg as { _isHtmlElement?: boolean })._isHtmlElement === true;
|
|
61
108
|
}
|
|
62
109
|
|
|
63
110
|
/**
|
|
@@ -95,7 +142,19 @@ export class HtmlElement {
|
|
|
95
142
|
html += ">";
|
|
96
143
|
|
|
97
144
|
for (const child of this.children) {
|
|
98
|
-
|
|
145
|
+
if (child instanceof HtmlElement) {
|
|
146
|
+
// Nested elements render themselves (already escape their own children).
|
|
147
|
+
html += child.toString();
|
|
148
|
+
} else if (child instanceof Raw) {
|
|
149
|
+
// Explicitly trusted markup — emit unescaped.
|
|
150
|
+
html += child.toString();
|
|
151
|
+
} else if (isBuilderElement(child)) {
|
|
152
|
+
// Callable builder produced by htmlElement() — render via its toString.
|
|
153
|
+
html += String(child);
|
|
154
|
+
} else {
|
|
155
|
+
// Plain string/scalar child — escape to defeat XSS.
|
|
156
|
+
html += escapeText(String(child));
|
|
157
|
+
}
|
|
99
158
|
}
|
|
100
159
|
|
|
101
160
|
html += `</${this.tag}>`;
|
|
@@ -45,6 +45,7 @@ export {
|
|
|
45
45
|
hashPassword, checkPassword,
|
|
46
46
|
authMiddleware,
|
|
47
47
|
refreshToken, authenticateRequest, validateApiKey,
|
|
48
|
+
ensureDevSecret,
|
|
48
49
|
Auth,
|
|
49
50
|
} from "./auth.js";
|
|
50
51
|
export { Session, FileSessionHandler, RedisSessionHandler, buildSessionCookie } from "./session.js";
|
|
@@ -57,14 +58,14 @@ export { Queue } from "./queue.js";
|
|
|
57
58
|
export type { QueueConfig, QueueJob, ProcessOptions } from "./queue.js";
|
|
58
59
|
export { createJob } from "./job.js";
|
|
59
60
|
export type { JobData, JobQueueBridge } from "./job.js";
|
|
60
|
-
export { GraphQL, ParseError, graphqlEndpoint, graphqlAutoSchemaEnabled } from "./graphql.js";
|
|
61
|
+
export { GraphQL, ParseError, graphqlEndpoint, graphqlAutoSchemaEnabled, graphqlMaxDepth } from "./graphql.js";
|
|
61
62
|
export type { GraphQLField, ResolverFn, GraphQLResult } from "./graphql.js";
|
|
62
63
|
export {
|
|
63
64
|
WebSocketServer,
|
|
64
65
|
devReloadWs,
|
|
65
|
-
computeAcceptKey, parseUpgradeHeaders, buildFrame, parseFrame,
|
|
66
|
+
computeAcceptKey, parseUpgradeHeaders, buildFrame, parseFrame, originAllowed,
|
|
66
67
|
OP_TEXT, OP_BINARY, OP_CLOSE, OP_PING, OP_PONG,
|
|
67
|
-
CLOSE_NORMAL, CLOSE_PROTOCOL_ERROR,
|
|
68
|
+
CLOSE_NORMAL, CLOSE_GOING_AWAY, CLOSE_PROTOCOL_ERROR, CLOSE_POLICY_VIOLATION,
|
|
68
69
|
} from "./websocket.js";
|
|
69
70
|
export type { WebSocketClient } from "./websocket.js";
|
|
70
71
|
export { ServiceRunner, Tina4Service, matchCronField, matchesCron } from "./service.js";
|
|
@@ -74,7 +75,7 @@ export type { ResponseCacheConfig, CacheBackend } from "./cache.js";
|
|
|
74
75
|
export { Api } from "./api.js";
|
|
75
76
|
export type { ApiResult } from "./api.js";
|
|
76
77
|
export { Events } from "./events.js";
|
|
77
|
-
export { DevAdmin, MessageLog, RequestInspector, ErrorTracker, DevMailboxStore, DevQueue, WsTracker, supervisorBaseUrl } from "./devAdmin.js";
|
|
78
|
+
export { DevAdmin, MessageLog, RequestInspector, ErrorTracker, DevMailboxStore, DevQueue, WsTracker, supervisorBaseUrl, devAdminLanguage } from "./devAdmin.js";
|
|
78
79
|
export {
|
|
79
80
|
feedbackEnabled,
|
|
80
81
|
feedbackWhitelist,
|
|
@@ -86,12 +87,12 @@ export {
|
|
|
86
87
|
handleFeedbackWidgetJs,
|
|
87
88
|
registerFeedbackRoutes,
|
|
88
89
|
} from "./feedback.js";
|
|
89
|
-
export { Messenger } from "./messenger.js";
|
|
90
|
+
export { Messenger, MessengerConnectionError } from "./messenger.js";
|
|
90
91
|
export type { SendResult, EmailMessage } from "./messenger.js";
|
|
91
92
|
export { DevMailbox, createMessenger } from "./devMailbox.js";
|
|
92
93
|
export { WSDLService, WSDLOperation } from "./wsdl.js";
|
|
93
94
|
export type { WSDLOperationMeta } from "./wsdl.js";
|
|
94
|
-
export { HtmlElement, htmlElement, addHtmlHelpers } from "./htmlElement.js";
|
|
95
|
+
export { HtmlElement, htmlElement, addHtmlHelpers, Raw, SafeString } from "./htmlElement.js";
|
|
95
96
|
export { renderErrorOverlay, renderProductionError, isDebugMode } from "./errorOverlay.js";
|
|
96
97
|
export { AI_TOOLS, isInstalled, showMenu, installSelected, installAll, generateContext } from "./ai.js";
|
|
97
98
|
export type { AiTool } from "./ai.js";
|
|
@@ -119,8 +120,13 @@ export { Container, container } from "./container.js";
|
|
|
119
120
|
export { Validator } from "./validator.js";
|
|
120
121
|
export type { ValidationError } from "./validator.js";
|
|
121
122
|
export type { WebSocketConnection } from "./websocketConnection.js";
|
|
122
|
-
export {
|
|
123
|
-
|
|
123
|
+
export {
|
|
124
|
+
RedisBackplane, NATSBackplane, createBackplane,
|
|
125
|
+
WsBackplaneManager, buildEnvelope, WS_BACKPLANE_CHANNEL,
|
|
126
|
+
} from "./websocketBackplane.js";
|
|
127
|
+
export type {
|
|
128
|
+
WebSocketBackplane, WsEnvelope, WsEnvelopeKind, WsBackplaneLogger,
|
|
129
|
+
} from "./websocketBackplane.js";
|
|
124
130
|
export {
|
|
125
131
|
McpServer, mcpTool, mcpResource, registerDevTools, getDefaultDevServer,
|
|
126
132
|
encodeResponse, encodeError, encodeNotification, decodeRequest,
|
|
@@ -100,7 +100,7 @@ const COLORS: Record<LogLevel, string> = {
|
|
|
100
100
|
const RESET = "\x1b[0m";
|
|
101
101
|
|
|
102
102
|
/** Regex to strip ANSI escape codes */
|
|
103
|
-
const ANSI_RE = /\
|
|
103
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
104
104
|
|
|
105
105
|
/** Default log directory */
|
|
106
106
|
const DEFAULT_LOG_DIR = "logs";
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
encodeResponse, encodeError, encodeNotification, decodeRequest,
|
|
7
7
|
McpServer, isLocalhost, schemaFromParams, registerDevTools,
|
|
8
8
|
PARSE_ERROR, METHOD_NOT_FOUND, INTERNAL_ERROR,
|
|
9
|
-
} from "./mcp.
|
|
9
|
+
} from "./mcp.js";
|
|
10
10
|
import * as fs from "node:fs";
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import * as os from "node:os";
|