tina4-nodejs 3.13.149 → 3.13.151

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 (60) hide show
  1. package/CLAUDE.md +2 -2
  2. package/package.json +5 -9
  3. package/packages/cli/dist/bin.js +1074 -521
  4. package/packages/core/dist/index.js +1071 -516
  5. package/packages/core/public/bundled-component-provenance.json +2 -2
  6. package/packages/core/public/js/tina4-dev-admin-split.html +202 -0
  7. package/packages/core/public/js/tina4-dev-admin.min.js +147 -138
  8. package/packages/core/src/aiClient.ts +2 -1
  9. package/packages/core/src/api.ts +2 -1
  10. package/packages/core/src/auth.ts +1 -1
  11. package/packages/core/src/cache.ts +6 -1
  12. package/packages/core/src/devAdmin.ts +209 -123
  13. package/packages/core/src/dispatchPipeline.ts +4 -0
  14. package/packages/core/src/docs.ts +5 -3
  15. package/packages/core/src/errorOverlay.ts +18 -9
  16. package/packages/core/src/index.ts +2 -1
  17. package/packages/core/src/mcp.ts +53 -16
  18. package/packages/core/src/messenger.ts +22 -30
  19. package/packages/core/src/metrics.ts +5 -1
  20. package/packages/core/src/middleware.ts +8 -1
  21. package/packages/core/src/optionalPackage.ts +2 -2
  22. package/packages/core/src/plan.ts +4 -4
  23. package/packages/core/src/push.ts +1 -1
  24. package/packages/core/src/request.ts +185 -25
  25. package/packages/core/src/router.ts +3 -2
  26. package/packages/core/src/server.ts +32 -10
  27. package/packages/core/src/session.ts +44 -0
  28. package/packages/core/src/slashTrim.ts +39 -0
  29. package/packages/core/src/wsdl.ts +66 -48
  30. package/packages/core/templates/crud/form.twig +5 -5
  31. package/packages/core/templates/crud/table.twig +2 -2
  32. package/packages/core/templates/errors/302.twig +4 -3
  33. package/packages/frond/dist/index.js +206 -64
  34. package/packages/frond/src/engine.ts +278 -69
  35. package/packages/frond/src/index.ts +1 -1
  36. package/packages/orm/dist/index.js +1055 -502
  37. package/packages/orm/src/crudRender.ts +5 -5
  38. package/packages/orm/src/crudTable.ts +1 -1
  39. package/packages/orm/src/database.ts +15 -29
  40. package/packages/orm/src/databaseResult.ts +11 -1
  41. package/packages/orm/src/realtime/index.ts +1 -0
  42. package/packages/orm/src/realtime/realtime.ts +2 -1
  43. package/packages/orm/src/realtime/s3Client.ts +276 -0
  44. package/packages/orm/src/realtime/storage.ts +19 -60
  45. package/packages/orm/src/sqlTranslator.ts +58 -42
  46. package/packages/swagger/dist/index.js +25 -4
  47. package/packages/swagger/src/generator.ts +7 -4
  48. package/packages/swagger/src/ui.ts +2 -1
  49. package/types/core/src/devAdmin.d.ts +40 -0
  50. package/types/core/src/index.d.ts +2 -1
  51. package/types/core/src/middleware.d.ts +1 -0
  52. package/types/core/src/optionalPackage.d.ts +2 -2
  53. package/types/core/src/request.d.ts +5 -1
  54. package/types/core/src/session.d.ts +25 -0
  55. package/types/core/src/slashTrim.d.ts +6 -0
  56. package/types/frond/src/engine.d.ts +11 -0
  57. package/types/frond/src/index.d.ts +1 -1
  58. package/types/orm/src/realtime/index.d.ts +1 -0
  59. package/types/orm/src/realtime/s3Client.d.ts +48 -0
  60. package/types/orm/src/realtime/storage.d.ts +9 -7
@@ -29,6 +29,53 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
29
29
 
30
30
  import { DatabaseUrl } from "./databaseUrl.js";
31
31
  import { DEFAULT_SRID, SpatialNotSupportedError } from "./point.js";
32
+
33
+ // ── placeholder-scan helpers ─────────────────────────────────
34
+ // The `?`-placeholder scanner skips over literals/comments it must not count.
35
+ // Each span-skip is a pure "return the index just past this span" helper so the
36
+ // scan loop itself stays flat (keeps placeholderPositions' complexity down).
37
+ const isIdentChar = (ch: string | undefined): boolean => !!ch && /[A-Za-z0-9_]/.test(ch);
38
+
39
+ /** Index just past the quoted string/identifier opening at i (sql[i] is the quote). */
40
+ function skipQuoted(sql: string, i: number, backslashEscapes: boolean): number {
41
+ const c = sql[i];
42
+ // E'...' (Postgres escape string) honours backslash escapes; so does every
43
+ // quoted string when the engine does (MySQL).
44
+ const escapeString = c === "'" && (sql[i - 1] === "E" || sql[i - 1] === "e") && !isIdentChar(sql[i - 2]);
45
+ const backslash = c !== "`" && (escapeString || backslashEscapes);
46
+ const n = sql.length;
47
+ i++;
48
+ while (i < n) {
49
+ if (backslash && sql[i] === "\\") { i += 2; continue; }
50
+ if (sql[i] === c) {
51
+ if (sql[i + 1] === c) { i += 2; continue; } // doubled quote = escaped quote
52
+ return i + 1;
53
+ }
54
+ i++;
55
+ }
56
+ return i;
57
+ }
58
+
59
+ /** Index just past the `-- ...` line comment opening at i. */
60
+ function skipLineComment(sql: string, i: number): number {
61
+ while (i < sql.length && sql[i] !== "\n") i++;
62
+ return i;
63
+ }
64
+
65
+ /** Index just past the block comment opening at i. */
66
+ function skipBlockComment(sql: string, i: number): number {
67
+ const end = sql.indexOf("*/", i + 2);
68
+ return end === -1 ? sql.length : end + 2;
69
+ }
70
+
71
+ /** Index just past the `$tag$ ... $tag$` dollar-quoted block opening at i, or i if none opens here. */
72
+ function skipDollarQuote(sql: string, i: number): number {
73
+ const tag = sql.slice(i).match(/^\$([A-Za-z_][A-Za-z0-9_]*)?\$/);
74
+ if (!tag) return i;
75
+ const end = sql.indexOf(tag[0], i + tag[0].length);
76
+ return end === -1 ? sql.length : end + tag[0].length;
77
+ }
78
+
32
79
  export class SQLTranslator {
33
80
  private static readonly SPATIAL_ENGINES = new Set(["postgres", "postgresql"]);
34
81
  private static readonly SPATIAL_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$/;
@@ -357,51 +404,17 @@ export class SQLTranslator {
357
404
  static placeholderPositions(sql: string, options: { backslashEscapes?: boolean } = {}): number[] {
358
405
  const positions: number[] = [];
359
406
  const n = sql.length;
360
- const isIdent = (ch: string | undefined): boolean => !!ch && /[A-Za-z0-9_]/.test(ch);
361
407
  let i = 0;
362
408
  while (i < n) {
363
409
  const c = sql[i];
364
410
  const next = sql[i + 1];
365
-
366
- if (c === "'" || c === '"' || c === "`") {
367
- // E'...' (Postgres escape string) honours backslash escapes; so does
368
- // every quoted string when the engine does (MySQL).
369
- const prev = sql[i - 1];
370
- const escapeString = c === "'" && (prev === "E" || prev === "e") && !isIdent(sql[i - 2]);
371
- const backslash = c !== "`" && (escapeString || options.backslashEscapes === true);
372
- i++;
373
- while (i < n) {
374
- if (backslash && sql[i] === "\\") { i += 2; continue; }
375
- if (sql[i] === c) {
376
- if (sql[i + 1] === c) { i += 2; continue; }
377
- i++;
378
- break;
379
- }
380
- i++;
381
- }
382
- continue;
383
- }
384
-
385
- if (c === "-" && next === "-") {
386
- while (i < n && sql[i] !== "\n") i++;
387
- continue;
388
- }
389
-
390
- if (c === "/" && next === "*") {
391
- const end = sql.indexOf("*/", i + 2);
392
- i = end === -1 ? n : end + 2;
393
- continue;
411
+ if (c === "'" || c === '"' || c === "`") { i = skipQuoted(sql, i, options.backslashEscapes === true); continue; }
412
+ if (c === "-" && next === "-") { i = skipLineComment(sql, i); continue; }
413
+ if (c === "/" && next === "*") { i = skipBlockComment(sql, i); continue; }
414
+ if (c === "$" && !isIdentChar(sql[i - 1])) {
415
+ const past = skipDollarQuote(sql, i);
416
+ if (past !== i) { i = past; continue; }
394
417
  }
395
-
396
- if (c === "$" && !isIdent(sql[i - 1])) {
397
- const tag = sql.slice(i).match(/^\$([A-Za-z_][A-Za-z0-9_]*)?\$/);
398
- if (tag) {
399
- const end = sql.indexOf(tag[0], i + tag[0].length);
400
- i = end === -1 ? n : end + tag[0].length;
401
- continue;
402
- }
403
- }
404
-
405
418
  if (c === "?") positions.push(i);
406
419
  i++;
407
420
  }
@@ -437,7 +450,7 @@ export class SQLTranslator {
437
450
  * → { sql: "INSERT INTO t (x) VALUES (1)", columns: ["id", "name"] }
438
451
  */
439
452
  static parseReturning(sql: string): { sql: string; columns: string[] } {
440
- const m = sql.match(/\bRETURNING\s+(.+)$/i);
453
+ const m = sql.match(/\bRETURNING\s+(\S.*)$/i);
441
454
  if (!m) return { sql, columns: [] };
442
455
  const columns = m[1].split(",").map((c) => c.trim());
443
456
  return {
@@ -496,7 +509,10 @@ export class SQLTranslator {
496
509
  // start of the VALUES group rather than by hunting for a parenthesis (the
497
510
  // column list has parentheses too).
498
511
  private static readonly INSERT_VALUES =
499
- /^\s*INSERT\s+INTO\s+.+?\s+VALUES\s*\(([^()]*)\)\s*$/dis;
512
+ // ReDoS-safe: the table ref matches `\S(?:[\s\S]*?\S)?` (bounded by non-space
513
+ // at both ends) so the separator `\s+` before VALUES can't overlap it the way
514
+ // the old `.+?\s+` did (polynomial on `INSERT INTO ` + a long space run).
515
+ /^\s*INSERT\s+INTO\s+\S(?:[\s\S]*?\S)?\s+VALUES\s*\(([^()]*)\)\s*$/dis;
500
516
 
501
517
  /**
502
518
  * Engines whose lastInsertId reports the FIRST generated id of a multi-row
@@ -1,3 +1,21 @@
1
+ // ../core/src/slashTrim.ts
2
+ var SLASH = 47;
3
+ function rtrimSlashes(s) {
4
+ let end = s.length;
5
+ while (end > 0 && s.charCodeAt(end - 1) === SLASH) end--;
6
+ return end === s.length ? s : s.slice(0, end);
7
+ }
8
+ function trimChar(s, code) {
9
+ let start = 0;
10
+ let end = s.length;
11
+ while (start < end && s.charCodeAt(start) === code) start++;
12
+ while (end > start && s.charCodeAt(end - 1) === code) end--;
13
+ return start === 0 && end === s.length ? s : s.slice(start, end);
14
+ }
15
+ function trimSlashes(s) {
16
+ return trimChar(s, SLASH);
17
+ }
18
+
1
19
  // src/generator.ts
2
20
  var WRITE_METHODS = /* @__PURE__ */ new Set(["post", "put", "patch", "delete"]);
3
21
  var INTERNAL_PREFIXES = ["/swagger", "/__dev", "/__feedback", "/ai", "/rag", "/vision", "/embed", "/image"];
@@ -85,7 +103,10 @@ function generate(routes, models = []) {
85
103
  openapi: resolveOpenApiVersion(),
86
104
  info: buildInfo(),
87
105
  servers: resolveServers(),
88
- paths: {},
106
+ // Prototype-less: route paths are developer-defined, but a null prototype
107
+ // makes `spec.paths[path][method] = op` structurally pollution-proof
108
+ // regardless (js/prototype-polluting-assignment). Serialises to JSON the same.
109
+ paths: /* @__PURE__ */ Object.create(null),
89
110
  components: {
90
111
  schemas: {},
91
112
  // Configurable security schemes (v3.13.42): bearerFormat via env, optional
@@ -150,7 +171,7 @@ function buildOperation(route, spec, ctx) {
150
171
  const openApiPath = patternToOpenAPI(route.pattern);
151
172
  const method = route.method.toLowerCase();
152
173
  if (!spec.paths[openApiPath]) {
153
- spec.paths[openApiPath] = {};
174
+ spec.paths[openApiPath] = /* @__PURE__ */ Object.create(null);
154
175
  }
155
176
  const tags = route.meta?.tags ?? inferTags(route.pattern);
156
177
  for (const t of tags) {
@@ -468,7 +489,7 @@ function inferModelFromPath(pattern) {
468
489
  return null;
469
490
  }
470
491
  function operationIdBase(method, openApiPath) {
471
- const clean = openApiPath.replace(/^\/+|\/+$/g, "").replace(/\//g, "_").replace(/\.\.\./g, "").replace(/[{}]/g, "").replace(/\*/g, "wildcard");
492
+ const clean = trimSlashes(openApiPath).replace(/\//g, "_").replace(/\.\.\./g, "").replace(/[{}]/g, "").replace(/\*/g, "wildcard");
472
493
  return clean ? `${method}_${clean}` : method;
473
494
  }
474
495
  function uniqueOperationId(method, openApiPath, seen) {
@@ -485,7 +506,7 @@ function uniqueOperationId(method, openApiPath, seen) {
485
506
 
486
507
  // src/ui.ts
487
508
  function swaggerUiCdn() {
488
- return (process.env.TINA4_SWAGGER_UI_CDN ?? "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5").replace(/\/+$/, "");
509
+ return rtrimSlashes(process.env.TINA4_SWAGGER_UI_CDN ?? "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5");
489
510
  }
490
511
  var SWAGGER_UI_HTML = (specUrl, nonce) => `<!DOCTYPE html>
491
512
  <html lang="en">
@@ -7,6 +7,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
7
  */
8
8
 
9
9
  import type { RouteDefinition } from "../../core/src/index.js";
10
+ import { trimSlashes } from "../../core/src/slashTrim.js";
10
11
  import type { ModelDefinition, FieldDefinition } from "../../orm/src/index.js";
11
12
 
12
13
  interface OpenAPISpecInfo {
@@ -191,7 +192,10 @@ export function generate(
191
192
  openapi: resolveOpenApiVersion(),
192
193
  info: buildInfo(),
193
194
  servers: resolveServers(),
194
- paths: {},
195
+ // Prototype-less: route paths are developer-defined, but a null prototype
196
+ // makes `spec.paths[path][method] = op` structurally pollution-proof
197
+ // regardless (js/prototype-polluting-assignment). Serialises to JSON the same.
198
+ paths: Object.create(null),
195
199
  components: {
196
200
  schemas: {},
197
201
  // Configurable security schemes (v3.13.42): bearerFormat via env, optional
@@ -297,7 +301,7 @@ function buildOperation(route: RouteDefinition, spec: OpenAPISpec, ctx: BuildCon
297
301
  const method = route.method.toLowerCase();
298
302
 
299
303
  if (!spec.paths[openApiPath]) {
300
- spec.paths[openApiPath] = {};
304
+ spec.paths[openApiPath] = Object.create(null);
301
305
  }
302
306
 
303
307
  const tags = route.meta?.tags ?? inferTags(route.pattern);
@@ -793,8 +797,7 @@ function inferModelFromPath(pattern: string): string | null {
793
797
  * distinct ids straight from the two distinct paths.
794
798
  */
795
799
  function operationIdBase(method: string, openApiPath: string): string {
796
- const clean = openApiPath
797
- .replace(/^\/+|\/+$/g, "") // strip leading/trailing slashes (Python .strip("/"))
800
+ const clean = trimSlashes(openApiPath) // strip leading/trailing slashes (ReDoS-safe)
798
801
  .replace(/\//g, "_") // internal slash -> underscore
799
802
  .replace(/\.\.\./g, "") // catch-all '...' inside braces -> nothing ({...slug} -> slug)
800
803
  .replace(/[{}]/g, "") // drop braces
@@ -7,6 +7,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
7
  */
8
8
 
9
9
  import type { Tina4Request, Tina4Response, RouteDefinition } from "../../core/src/index.js";
10
+ import { rtrimSlashes } from "../../core/src/slashTrim.js";
10
11
 
11
12
  // The UI assets load from a CDN by default (a documented architecture decision —
12
13
  // we don't vendor ~1.4MB of swagger-ui-dist, to stay small). jsdelivr
@@ -15,7 +16,7 @@ import type { Tina4Request, Tina4Response, RouteDefinition } from "../../core/sr
15
16
  // splitting jsdelivr/unpkg. Air-gapped deployments point TINA4_SWAGGER_UI_CDN
16
17
  // at a self-hosted mirror (a base URL serving swagger-ui.css + swagger-ui-bundle.js).
17
18
  function swaggerUiCdn(): string {
18
- return (process.env.TINA4_SWAGGER_UI_CDN ?? "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5").replace(/\/+$/, "");
19
+ return rtrimSlashes(process.env.TINA4_SWAGGER_UI_CDN ?? "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5");
19
20
  }
20
21
 
21
22
  // The inline <style> and the inline SwaggerUIBundle(...) <script> carry this
@@ -221,6 +221,46 @@ export declare class DevAdmin {
221
221
  reload?: boolean;
222
222
  }): string;
223
223
  }
224
+ /** A column as the DB adapter's getColumns() reports it, for seeding. */
225
+ interface SeedColumn {
226
+ name: string;
227
+ type: string;
228
+ primaryKey?: boolean;
229
+ }
230
+ /** The fake-data surface buildSeedFieldMap draws from (orm's FakeData). */
231
+ interface SeedFake {
232
+ email(): string;
233
+ name(): string;
234
+ phone(): string;
235
+ address(): string;
236
+ url(): string;
237
+ integer(min: number, max: number): number;
238
+ numeric(min: number, max: number, dp: number): number;
239
+ boolean(): boolean;
240
+ date(): unknown;
241
+ sentence(words: number): string;
242
+ }
243
+ /**
244
+ * Resolve the seed request's options from the query string and JSON body (the
245
+ * query string wins). Exported so the parsing is tested directly. P4b accepts
246
+ * seed/clear/strict; an unparseable `seed` becomes undefined (not reproducible).
247
+ */
248
+ export declare function parseSeedRequest(url: URL, body: Record<string, unknown> | null | undefined): {
249
+ table: string;
250
+ count: number;
251
+ seed: number | undefined;
252
+ clear: boolean;
253
+ strict: boolean;
254
+ };
255
+ /**
256
+ * Build a column -> fake-value generator map from the table's columns, keyed on
257
+ * the column NAME first (email/name/phone/address/url) then its SQL TYPE
258
+ * (int/float/bool/date), falling back to a short sentence. id / primary-key
259
+ * columns are skipped (the engine assigns them). Exported so the heuristic is
260
+ * tested directly. `fake` is seeded by the caller so a run is reproducible; its
261
+ * generators are closed over by every entry.
262
+ */
263
+ export declare function buildSeedFieldMap(columns: SeedColumn[], fake: SeedFake): Record<string, () => unknown>;
224
264
  /**
225
265
  * Return the base URL for the co-located Rust agent server.
226
266
  *
@@ -8,6 +8,7 @@ export { discoverRoutes } from "./routeDiscovery.js";
8
8
  export { MiddlewareChain, MiddlewareRunner, cors, requestLogger, CorsMiddleware, RateLimiterMiddleware, RequestLogger, SecurityHeadersMiddleware, CsrfMiddleware, attachCsrfFromEnv } from "./middleware.js";
9
9
  export type { CorsConfig } from "./middleware.js";
10
10
  export { createRequest, makeCaseInsensitiveHeaders, parseMultipart, saveUpload } from "./request.js";
11
+ export type { FormValue } from "./request.js";
11
12
  export { createResponse, errorResponse, setDefaultTemplatesDir, getFrond, setFrond, getFrameworkFrond, acceptPrefersJson, wantsJson, negotiatedErrorBody, } from "./response.js";
12
13
  export { tryServeStatic } from "./static.js";
13
14
  export { loadEnv, getEnv, requireEnv, hasEnv, allEnv, resetEnv, isTruthy } from "./dotenv.js";
@@ -45,7 +46,7 @@ export type { ApiResult, ApiOptions, ApiTransport, DownloadResult, UploadOptions
45
46
  export { Context, defaultContext, existingContext, fts5Supported, _sharedContexts } from "./context/index.js";
46
47
  export type { SearchHit } from "./context/index.js";
47
48
  export { Events } from "./events.js";
48
- export { DevAdmin, MessageLog, RequestInspector, ErrorTracker, DevMailboxStore, DevQueue, WsTracker, supervisorBaseUrl, devAdminLanguage } from "./devAdmin.js";
49
+ export { DevAdmin, MessageLog, RequestInspector, ErrorTracker, DevMailboxStore, DevQueue, WsTracker, supervisorBaseUrl, devAdminLanguage, parseSeedRequest, buildSeedFieldMap } from "./devAdmin.js";
49
50
  export { feedbackEnabled, feedbackWhitelist, feedbackIdentifyUser, feedbackIsWhitelisted, feedbackRateLimitOk, injectFeedbackWidget, handleFeedbackTurn, handleFeedbackWidgetJs, registerFeedbackRoutes, } from "./feedback.js";
50
51
  export { Messenger, MessengerConnectionError, createMessenger } from "./messenger.js";
51
52
  export type { SendResult, EmailMessage } from "./messenger.js";
@@ -325,6 +325,7 @@ export declare class RequestLogger {
325
325
  * TINA4_CSP — Content-Security-Policy (default: "default-src 'self'")
326
326
  * TINA4_REFERRER_POLICY — Referrer-Policy (default: "strict-origin-when-cross-origin")
327
327
  * TINA4_PERMISSIONS_POLICY — Permissions-Policy (default: "camera=(), microphone=(), geolocation=()")
328
+ * TINA4_COOP — Cross-Origin-Opener-Policy (default: "same-origin-allow-popups")
328
329
  *
329
330
  * Usage:
330
331
  * Router.use(SecurityHeadersMiddleware);
@@ -3,8 +3,8 @@
3
3
  * APPLICATION installs, never the framework (ADR-0067).
4
4
  *
5
5
  * tina4-nodejs installs as exactly one package. `pg`, `mongodb`, `redis` and
6
- * the `@aws-sdk` S3 client are optional peerDependencies, which npm does not
7
- * install, so for most apps "missing" is the normal state. Every feature that
6
+ * the SQL drivers are optional peerDependencies, which npm does not install, so
7
+ * for most apps "missing" is the normal state. Every feature that
8
8
  * reaches for one must therefore fail at the point of use with the exact
9
9
  * command that fixes it - never a bare MODULE_NOT_FOUND, never a silent no-op.
10
10
  */
@@ -32,7 +32,7 @@ export declare class PayloadTooLargeError extends Error {
32
32
  * Zero-dependency implementation.
33
33
  */
34
34
  export declare function parseMultipart(body: Buffer, boundary: string): {
35
- fields: Record<string, string>;
35
+ fields: Record<string, FormValue>;
36
36
  files: Record<string, UploadedFile | UploadedFile[]>;
37
37
  };
38
38
  /**
@@ -50,3 +50,7 @@ export declare function parseMultipart(body: Buffer, boundary: string): {
50
50
  * @throws when the derived name is unsafe or would escape `targetDir`.
51
51
  */
52
52
  export declare function saveUpload(file: UploadedFile, targetDir: string, filename?: string): string;
53
+ /** A form field's value once its name is nested: `tags[]` a list, `user[name]` an object. */
54
+ export type FormValue = string | FormValue[] | {
55
+ [key: string]: FormValue;
56
+ };
@@ -413,4 +413,29 @@ export declare function sessionCookieToSend(sess: {
413
413
  }, rawRes: {
414
414
  headersSent: boolean;
415
415
  }): string | null;
416
+ /**
417
+ * The session of the request being dispatched, for CSRF form-token binding.
418
+ *
419
+ * Frond's form_token() reads it through the provider core registers
420
+ * (setFormTokenSessionProvider), so a token is bound to the session that
421
+ * rendered it. It lives in an AsyncLocalStorage, like the CSP nonce, so two
422
+ * requests interleaving on the one event loop never bind to each other's session.
423
+ */
424
+ interface BindableSession {
425
+ getSessionId(): string | null;
426
+ isFresh(): boolean;
427
+ set(key: string, value: unknown): void;
428
+ }
429
+ /** Run `fn` with a request scope the dispatcher publishes its session into. */
430
+ export declare function runWithFormTokenSession<T>(fn: () => T): T;
431
+ /** Publish the current request's session (no-op outside a request scope). */
432
+ export declare function setFormTokenSession(session: BindableSession | null): void;
433
+ /**
434
+ * The current request's session id, or "" outside a request.
435
+ *
436
+ * A session that holds nothing is sent no cookie, so the form's own POST would
437
+ * arrive under a newly minted id and fail its own token. Rendering a bound
438
+ * token therefore stores a marker, which makes the session persist.
439
+ */
440
+ export declare function currentFormTokenSessionId(): string;
416
441
  export {};
@@ -0,0 +1,6 @@
1
+ /** Strip the given char code from the end only (the `/\/+$/` replacement). */
2
+ export declare function rtrimSlashes(s: string): string;
3
+ /** Strip the given char code from both ends (linear, no backtracking). */
4
+ export declare function trimChar(s: string, code: number): string;
5
+ /** Strip leading AND trailing '/' (the ReDoS-safe `/^\/+|\/+$/g` replacement). */
6
+ export declare function trimSlashes(s: string): string;
@@ -47,6 +47,12 @@ export declare const TEMPLATE_CACHE_MAX = 256;
47
47
  * string, the same order of magnitude as a compiled template.
48
48
  */
49
49
  export declare const MEMO_CACHE_MAX = 1024;
50
+ /**
51
+ * Integers from start to end inclusive, counting down when start is greater
52
+ * than end. Backs both range() and `a..b`; [] for a bound that is not a
53
+ * number or a zero step.
54
+ */
55
+ export declare function frondRange(start: unknown, end: unknown, step?: unknown): number[];
50
56
  /** Cached expression dispatcher branch; exported only for cache-bound verification. */
51
57
  export declare const expressionFormCache: Map<string, number>;
52
58
  /**
@@ -59,6 +65,11 @@ export declare function escapeHtml(value: unknown): string;
59
65
  * Set the session ID used by formToken() / form_token() for CSRF session binding.
60
66
  */
61
67
  export declare function setFormTokenSessionId(sessionId: string): void;
68
+ /**
69
+ * Register the function that gives form_token() the current request's session
70
+ * id, so each token is bound to the session that rendered it (CSRF).
71
+ */
72
+ export declare function setFormTokenSessionProvider(fn: () => string): void;
62
73
  /**
63
74
  * Register the function that backs the `{{ csp_nonce() }}` template global.
64
75
  * @tina4/core calls this once with its request-scoped nonce source so every
@@ -1,2 +1,2 @@
1
- export { Frond, escapeHtml, setCspNonceProvider } from "./engine.js";
1
+ export { Frond, escapeHtml, setCspNonceProvider, setFormTokenSessionProvider } from "./engine.js";
2
2
  export type { FilterFn, TestFn, LiveProvider, LiveRequest, LiveResponse, LiveBroadcaster } from "./engine.js";
@@ -1,5 +1,6 @@
1
1
  export { realtime, iceServers, type RealtimeOptions } from "./realtime.js";
2
2
  export { LocalStorage, S3Storage, selectStorage, storageKey, type StorageBackend, } from "./storage.js";
3
+ export { S3Client, S3Error, S3ConfigError, uriEncode } from "./s3Client.js";
3
4
  export { default as Workspace } from "./models/workspace.js";
4
5
  export { default as Channel } from "./models/channel.js";
5
6
  export { default as ChannelMember } from "./models/channelMember.js";
@@ -0,0 +1,48 @@
1
+ /** An S3 request returned a non-2xx status; `status` holds the HTTP code. */
2
+ export declare class S3Error extends Error {
3
+ readonly status: number;
4
+ /** Seconds from a Retry-After header in its delta-seconds form, else null. */
5
+ retryAfter: number | null;
6
+ constructor(status: number, message: string);
7
+ }
8
+ /** The client cannot sign a request: no access key or secret was given. */
9
+ export declare class S3ConfigError extends Error {
10
+ constructor(message: string);
11
+ }
12
+ /**
13
+ * SigV4 canonical encoding: keep the RFC 3986 unreserved set literal; keep "/"
14
+ * in a path and encode it in a query; every other byte becomes %XX (upper-case).
15
+ */
16
+ export declare function uriEncode(value: string, isPath: boolean): string;
17
+ export declare class S3Client {
18
+ private readonly scheme;
19
+ private readonly host;
20
+ private readonly prefix;
21
+ private readonly region;
22
+ private readonly accessKey;
23
+ private readonly secretKey;
24
+ private readonly timeout;
25
+ constructor(opts?: {
26
+ endpoint?: string;
27
+ accessKey?: string;
28
+ secretKey?: string;
29
+ region?: string;
30
+ timeout?: number;
31
+ });
32
+ putObject(bucket: string, key: string, data: Buffer | string, contentType?: string): Promise<void>;
33
+ getObject(bucket: string, key: string): Promise<Buffer>;
34
+ deleteObject(bucket: string, key: string): Promise<void>;
35
+ headObject(bucket: string, key: string): Promise<void>;
36
+ /** Create the bucket (path-style `PUT /<bucket>`); an already-owned bucket (409) is success. */
37
+ createBucket(bucket: string): Promise<void>;
38
+ /** A URL that fetches the object directly, signed via SigV4 query params. */
39
+ presignedGetUrl(bucket: string, key: string, expires?: number): string;
40
+ private requireCredentials;
41
+ private now;
42
+ private canonicalUri;
43
+ private canonicalQuery;
44
+ private signingKey;
45
+ private sign;
46
+ private request;
47
+ private send;
48
+ }
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Pluggable file storage for the realtime "files" feature. StorageBackend is
3
3
  * the interface; LocalStorage (default, zero-dependency, filesystem) and
4
- * S3Storage (opt-in, S3-compatible via @aws-sdk/client-s3) are the shipped
5
- * implementations. Selection mirrors the cache/session/queue backend pattern:
4
+ * S3Storage (opt-in, S3-compatible, also zero-dependency — it uses Tina4's own
5
+ * stdlib SigV4 client, not @aws-sdk) are the shipped implementations.
6
+ * Selection mirrors the cache/session/queue backend pattern:
6
7
  * TINA4_STORAGE_BACKEND (local default | s3) plus per-backend env vars, with a
7
8
  * graceful fallback to local if an s3 backend cannot be constructed - a real
8
9
  * persistent store, never a silent no-op. Parity with Python's storage.py.
@@ -33,10 +34,11 @@ export declare class LocalStorage implements StorageBackend {
33
34
  exists(key: string): boolean;
34
35
  }
35
36
  /**
36
- * S3-compatible store (AWS S3, MinIO, ...). Opt-in; the app installs the SDK:
37
- * `npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner`.
38
- * Presigned GET URLs let clients fetch large blobs straight from object
39
- * storage instead of streaming through the app.
37
+ * S3-compatible store (AWS S3, MinIO, Garage, ...). Opt-in, zero dependencies.
38
+ *
39
+ * Talks to S3 through Tina4's own stdlib SigV4 client (S3Client, node:crypto +
40
+ * global fetch) — no @aws-sdk packages. Presigned GET URLs let clients fetch
41
+ * large blobs straight from object storage instead of streaming through the app.
40
42
  */
41
43
  export declare class S3Storage implements StorageBackend {
42
44
  private client;
@@ -57,6 +59,6 @@ export declare class S3Storage implements StorageBackend {
57
59
  /**
58
60
  * Resolve the storage backend from an explicit instance or the environment.
59
61
  * Falls back to LocalStorage (logging why) when an s3 backend cannot be built
60
- * (driver missing or config incomplete) - a real store, never a silent no-op.
62
+ * (config incomplete, e.g. no bucket) - a real store, never a silent no-op.
61
63
  */
62
64
  export declare function selectStorage(storage?: StorageBackend): StorageBackend;