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
@@ -73,13 +73,13 @@ function renderModalForm(mode: string, columns: string[], pk: string, edit: bool
73
73
  value: "",
74
74
  placeholder: h(`Enter ${label.toLowerCase()}`),
75
75
  type: "text",
76
- required_attr: "",
76
+ required: false,
77
77
  input: true,
78
78
  };
79
79
  });
80
80
  return renderCrudTemplate("form", {
81
81
  wrap: true,
82
- form_id_attr: ` id="form-${mode}"`,
82
+ form_id: `form-${mode}`,
83
83
  action: "",
84
84
  form_method: "POST",
85
85
  method_override: null,
@@ -150,7 +150,7 @@ function fieldInputShape(field: FormFieldDef, base: Record<string, unknown>): Re
150
150
  case "text":
151
151
  return { ...base, textarea: true };
152
152
  case "boolean":
153
- return { ...base, checkbox: true, checked_attr: value ? " checked" : "" };
153
+ return { ...base, checkbox: true, checked: !!value };
154
154
  case "select":
155
155
  return { ...base, select: true, options_html: buildOptions(field.options, value) };
156
156
  case "date":
@@ -174,7 +174,7 @@ function buildCustomField(field: FormFieldDef): Record<string, unknown> {
174
174
  label: h(String(label)),
175
175
  value: h(field.value ?? ""),
176
176
  placeholder: "",
177
- required_attr: field.required ? " required" : "",
177
+ required: !!field.required,
178
178
  };
179
179
  return fieldInputShape(field, base);
180
180
  }
@@ -190,7 +190,7 @@ export function generateForm(
190
190
  const verb = String(options.method ?? "POST").toUpperCase();
191
191
  return renderCrudTemplate("form", {
192
192
  wrap: true,
193
- form_id_attr: "",
193
+ form_id: "",
194
194
  action: h(options.action ?? "/"),
195
195
  form_method: h(verb),
196
196
  method_override: ["PUT", "PATCH", "DELETE"].includes(verb) ? verb : null,
@@ -165,7 +165,7 @@ export function tableData(opts: TableDataOpts): Record<string, unknown> {
165
165
  readonly: !opts.editable,
166
166
  inline_script: opts.inlineScript,
167
167
  table_name: h(opts.tableName),
168
- table_id_attr: opts.tableId ? ` id="${h(opts.tableId)}"` : "",
168
+ table_id: opts.tableId ? String(opts.tableId) : "",
169
169
  };
170
170
  }
171
171
 
@@ -27,7 +27,7 @@ import { quoteIdentifierAnsi, assertColumnNames } from "./adapters/sqlDialect.js
27
27
  */
28
28
  export function stripTrailingSemicolons(sql: string): string {
29
29
  if (!sql) return sql;
30
- let stripped = sql.replace(/\s+$/, "");
30
+ let stripped = sql.trimEnd();
31
31
  while (stripped.endsWith(";")) {
32
32
  stripped = stripped.slice(0, -1).replace(/\s+$/, "");
33
33
  }
@@ -1800,6 +1800,16 @@ export async function createAdapterFromUrl(url: string, username?: string, passw
1800
1800
  async function buildAdapterFromUrl(url: string, username?: string, password?: string): Promise<DatabaseAdapter> {
1801
1801
  const parsed = parseDatabaseUrl(url, username, password);
1802
1802
 
1803
+ // The four TCP engines (postgres/mysql/mssql/firebird) take the identical
1804
+ // connection shape from the parsed URL; build it once instead of per case.
1805
+ const serverConfig = {
1806
+ host: parsed.host ?? undefined,
1807
+ port: parsed.port ?? undefined,
1808
+ user: parsed.username ?? undefined,
1809
+ password: parsed.password ?? undefined,
1810
+ database: parsed.database || undefined,
1811
+ };
1812
+
1803
1813
  switch (parsed.engine) {
1804
1814
  case "sqlite": {
1805
1815
  const { SQLiteAdapter } = await import("./adapters/sqlite.js");
@@ -1807,49 +1817,25 @@ async function buildAdapterFromUrl(url: string, username?: string, password?: st
1807
1817
  }
1808
1818
  case "postgres": {
1809
1819
  const { PostgresAdapter } = await import("./adapters/postgres.js");
1810
- const adapter = new PostgresAdapter({
1811
- host: parsed.host ?? undefined,
1812
- port: parsed.port ?? undefined,
1813
- user: parsed.username ?? undefined,
1814
- password: parsed.password ?? undefined,
1815
- database: parsed.database || undefined,
1816
- });
1820
+ const adapter = new PostgresAdapter(serverConfig);
1817
1821
  await adapter.connect();
1818
1822
  return adapter;
1819
1823
  }
1820
1824
  case "mysql": {
1821
1825
  const { MysqlAdapter } = await import("./adapters/mysql.js");
1822
- const adapter = new MysqlAdapter({
1823
- host: parsed.host ?? undefined,
1824
- port: parsed.port ?? undefined,
1825
- user: parsed.username ?? undefined,
1826
- password: parsed.password ?? undefined,
1827
- database: parsed.database || undefined,
1828
- });
1826
+ const adapter = new MysqlAdapter(serverConfig);
1829
1827
  await adapter.connect();
1830
1828
  return adapter;
1831
1829
  }
1832
1830
  case "mssql": {
1833
1831
  const { MssqlAdapter } = await import("./adapters/mssql.js");
1834
- const adapter = new MssqlAdapter({
1835
- host: parsed.host ?? undefined,
1836
- port: parsed.port ?? undefined,
1837
- user: parsed.username ?? undefined,
1838
- password: parsed.password ?? undefined,
1839
- database: parsed.database || undefined,
1840
- });
1832
+ const adapter = new MssqlAdapter(serverConfig);
1841
1833
  await adapter.connect();
1842
1834
  return adapter;
1843
1835
  }
1844
1836
  case "firebird": {
1845
1837
  const { FirebirdAdapter } = await import("./adapters/firebird.js");
1846
- const adapter = new FirebirdAdapter({
1847
- host: parsed.host ?? undefined,
1848
- port: parsed.port ?? undefined,
1849
- user: parsed.username ?? undefined,
1850
- password: parsed.password ?? undefined,
1851
- database: parsed.database || undefined,
1852
- });
1838
+ const adapter = new FirebirdAdapter(serverConfig);
1853
1839
  await adapter.connect();
1854
1840
  return adapter;
1855
1841
  }
@@ -8,6 +8,16 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
8
8
 
9
9
  import type { DatabaseAdapter, ColumnInfo } from "./types.js";
10
10
 
11
+ // Strip the "(args)" from a column type ("VARCHAR(255)" -> "VARCHAR",
12
+ // "INT(11) UNSIGNED" -> "INT UNSIGNED"). ReDoS-safe: indexOf/slice, not the old
13
+ // /\([^)]*\)/ which backtracks O(n^2) across many '(' (js/polynomial-redos).
14
+ function stripTypeArgs(colType: string): string {
15
+ const open = colType.indexOf("(");
16
+ if (open < 0) return colType;
17
+ const close = colType.indexOf(")", open);
18
+ return close < 0 ? colType : colType.slice(0, open) + colType.slice(close + 1);
19
+ }
20
+
11
21
  /** Column metadata returned by columnInfo(). */
12
22
  export interface ColumnInfoResult {
13
23
  name: string;
@@ -261,7 +271,7 @@ export class DatabaseResult implements Iterable<Record<string, unknown>> {
261
271
  const [size, decimals] = this._parseTypeSize(colType);
262
272
  return {
263
273
  name: col.name,
264
- type: colType.replace(/\(.*\)/, ""),
274
+ type: stripTypeArgs(colType),
265
275
  size,
266
276
  decimals,
267
277
  nullable: col.nullable ?? true,
@@ -14,6 +14,7 @@ export {
14
14
  storageKey,
15
15
  type StorageBackend,
16
16
  } from "./storage.js";
17
+ export { S3Client, S3Error, S3ConfigError, uriEncode } from "./s3Client.js";
17
18
  export { default as Workspace } from "./models/workspace.js";
18
19
  export { default as Channel } from "./models/channel.js";
19
20
  export { default as ChannelMember } from "./models/channelMember.js";
@@ -7,6 +7,7 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
7
  */
8
8
 
9
9
  import { createHmac } from "node:crypto";
10
+ import { trimSlashes } from "../../../core/src/slashTrim.js";
10
11
  import {
11
12
  Router,
12
13
  HTTP_OK,
@@ -87,7 +88,7 @@ let _authorize: RealtimeOptions["authorize"] | undefined;
87
88
  */
88
89
  export async function realtime(options: RealtimeOptions = {}): Promise<Record<string, string>> {
89
90
  const features = options.features ?? ["calls"];
90
- const raw = (options.prefix ?? "").replace(/^\/+|\/+$/g, "");
91
+ const raw = trimSlashes(options.prefix ?? "");
91
92
  const p = raw ? `/${raw}` : "";
92
93
  _authorize = options.authorize;
93
94
 
@@ -0,0 +1,276 @@
1
+ /*
2
+ Copyright (c) 2026 Code Infinity
3
+ SPDX-License-Identifier: MPL-2.0
4
+ This Source Code Form is subject to the terms of the Mozilla Public
5
+ License, v. 2.0. If a copy of the MPL was not distributed with this
6
+ file, You can obtain one at https://mozilla.org/MPL/2.0/.
7
+ */
8
+
9
+ import { createHash, createHmac } from "node:crypto";
10
+
11
+ /**
12
+ * Zero-dependency S3 client (AWS Signature Version 4) on the Node standard
13
+ * library — node:crypto for signing, global fetch for transport. Covers exactly
14
+ * what the realtime S3Storage backend needs (PUT, GET, DELETE, HEAD, a presigned
15
+ * GET URL, and create-bucket for provisioning) so the `s3` backend no longer
16
+ * needs @aws-sdk/client-s3 or @aws-sdk/s3-request-presigner.
17
+ *
18
+ * Path-style addressing (<endpoint>/<bucket>/<key>), which S3-compatible servers
19
+ * (MinIO, Garage) require and AWS accepts; with no endpoint it targets
20
+ * https://s3.<region>.amazonaws.com. SigV4 is HMAC-SHA256 canonical signing —
21
+ * the same primitive Tina4's JWT already uses (node:crypto); nothing here is
22
+ * reinvented crypto. Mirrors the Python master, proven against AWS's published
23
+ * SigV4 test vector.
24
+ */
25
+
26
+ const ALGORITHM = "AWS4-HMAC-SHA256";
27
+ const SERVICE = "s3";
28
+ const UNRESERVED = /[A-Za-z0-9\-_.~]/;
29
+
30
+ // Retry policy, as for the AI client (ADR-0053): HTTP 429, any 5xx (S3's
31
+ // SlowDown is a 503) and a dropped connection are sent again, a bounded number
32
+ // of times, honouring Retry-After. Every operation here is idempotent.
33
+ const MAX_RETRIES = 2;
34
+ const BACKOFF_BASE = 0.5; // seconds; retry n waits up to base * 2**n, jittered
35
+ const BACKOFF_CAP = 20.0;
36
+
37
+ /** An S3 request returned a non-2xx status; `status` holds the HTTP code. */
38
+ export class S3Error extends Error {
39
+ /** Seconds from a Retry-After header in its delta-seconds form, else null. */
40
+ retryAfter: number | null = null;
41
+ constructor(public readonly status: number, message: string) {
42
+ super(message);
43
+ this.name = "S3Error";
44
+ }
45
+ }
46
+
47
+ /** The client cannot sign a request: no access key or secret was given. */
48
+ export class S3ConfigError extends Error {
49
+ constructor(message: string) {
50
+ super(message);
51
+ this.name = "S3ConfigError";
52
+ }
53
+ }
54
+
55
+ /** Seconds from a Retry-After header in its delta-seconds form, else null. */
56
+ function retryAfterSeconds(raw: string | null): number | null {
57
+ if (raw == null) return null;
58
+ const n = Number(raw);
59
+ return Number.isFinite(n) ? Math.max(0, n) : null;
60
+ }
61
+
62
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
63
+
64
+ /**
65
+ * SigV4 canonical encoding: keep the RFC 3986 unreserved set literal; keep "/"
66
+ * in a path and encode it in a query; every other byte becomes %XX (upper-case).
67
+ */
68
+ export function uriEncode(value: string, isPath: boolean): string {
69
+ let out = "";
70
+ for (const byte of Buffer.from(value, "utf8")) {
71
+ const ch = String.fromCharCode(byte);
72
+ if (UNRESERVED.test(ch) || (isPath && ch === "/")) out += ch;
73
+ else out += "%" + byte.toString(16).toUpperCase().padStart(2, "0");
74
+ }
75
+ return out;
76
+ }
77
+
78
+ function sha256Hex(data: Buffer | string): string {
79
+ return createHash("sha256").update(data).digest("hex");
80
+ }
81
+
82
+ function hmac(key: Buffer | string, msg: string): Buffer {
83
+ return createHmac("sha256", key).update(msg, "utf8").digest();
84
+ }
85
+
86
+ export class S3Client {
87
+ private readonly scheme: string;
88
+ private readonly host: string; // authority (host[:port]) — matches the Host header
89
+ private readonly prefix: string; // path an endpoint carries (https://host/s3 -> "/s3"), "" if none
90
+ private readonly region: string;
91
+ private readonly accessKey: string;
92
+ private readonly secretKey: string;
93
+ private readonly timeout: number;
94
+
95
+ constructor(opts: {
96
+ endpoint?: string;
97
+ accessKey?: string;
98
+ secretKey?: string;
99
+ region?: string;
100
+ timeout?: number;
101
+ } = {}) {
102
+ this.region = opts.region || "us-east-1";
103
+ this.accessKey = opts.accessKey || "";
104
+ this.secretKey = opts.secretKey || "";
105
+ this.timeout = opts.timeout ?? 30000;
106
+ let base = opts.endpoint || `https://s3.${this.region}.amazonaws.com`;
107
+ while (base.endsWith("/")) base = base.slice(0, -1); // strip trailing slashes without a regex
108
+ // Split scheme :// authority [ / path ]. The authority alone is the Host
109
+ // header and the signed `host:`; an endpoint behind a path (https://host/s3)
110
+ // keeps that path in front of every request path, and in what is signed.
111
+ const m = /^([a-z][a-z0-9+.-]*):\/\/([^/]+)(\/.*)?$/i.exec(base);
112
+ this.scheme = m ? m[1] : "https";
113
+ this.host = m ? m[2] : base;
114
+ let p = m && m[3] ? m[3] : "";
115
+ while (p.endsWith("/")) p = p.slice(0, -1);
116
+ this.prefix = p;
117
+ }
118
+
119
+ // -- public API (the subset S3Storage uses) --------------------------------
120
+
121
+ async putObject(bucket: string, key: string, data: Buffer | string, contentType = "application/octet-stream"): Promise<void> {
122
+ const body = Buffer.isBuffer(data) ? data : Buffer.from(data);
123
+ await this.request("PUT", this.canonicalUri(bucket, key), body, { "content-type": contentType });
124
+ }
125
+
126
+ async getObject(bucket: string, key: string): Promise<Buffer> {
127
+ return (await this.request("GET", this.canonicalUri(bucket, key), Buffer.alloc(0))).body;
128
+ }
129
+
130
+ async deleteObject(bucket: string, key: string): Promise<void> {
131
+ await this.request("DELETE", this.canonicalUri(bucket, key), Buffer.alloc(0));
132
+ }
133
+
134
+ async headObject(bucket: string, key: string): Promise<void> {
135
+ await this.request("HEAD", this.canonicalUri(bucket, key), Buffer.alloc(0));
136
+ }
137
+
138
+ /** Create the bucket (path-style `PUT /<bucket>`); an already-owned bucket (409) is success. */
139
+ async createBucket(bucket: string): Promise<void> {
140
+ try {
141
+ await this.request("PUT", `${this.prefix}/${uriEncode(bucket, true)}`, Buffer.alloc(0));
142
+ } catch (e) {
143
+ if (!(e instanceof S3Error) || e.status !== 409) throw e;
144
+ }
145
+ }
146
+
147
+ /** A URL that fetches the object directly, signed via SigV4 query params. */
148
+ presignedGetUrl(bucket: string, key: string, expires = 3600): string {
149
+ this.requireCredentials();
150
+ const { amzDate, dateStamp } = this.now();
151
+ const uri = this.canonicalUri(bucket, key);
152
+ const scope = `${dateStamp}/${this.region}/${SERVICE}/aws4_request`;
153
+ const query: Record<string, string> = {
154
+ "X-Amz-Algorithm": ALGORITHM,
155
+ "X-Amz-Credential": `${this.accessKey}/${scope}`,
156
+ "X-Amz-Date": amzDate,
157
+ "X-Amz-Expires": String(expires),
158
+ "X-Amz-SignedHeaders": "host",
159
+ };
160
+ const canonicalQs = this.canonicalQuery(query);
161
+ const canonicalRequest = ["GET", uri, canonicalQs, `host:${this.host}\n`, "host", "UNSIGNED-PAYLOAD"].join("\n");
162
+ const signature = this.sign(canonicalRequest, amzDate, dateStamp, scope);
163
+ return `${this.scheme}://${this.host}${uri}?${canonicalQs}&X-Amz-Signature=${signature}`;
164
+ }
165
+
166
+ // -- signing + transport ----------------------------------------------------
167
+
168
+ private requireCredentials(): void {
169
+ // Signing with an empty key only earns a 403 from the server, which says
170
+ // nothing about the cause. Name the settings instead, before sending.
171
+ if (!this.accessKey || !this.secretKey) {
172
+ throw new S3ConfigError(
173
+ "S3 credentials are not set: set TINA4_STORAGE_KEY and TINA4_STORAGE_SECRET",
174
+ );
175
+ }
176
+ }
177
+
178
+ private now(): { amzDate: string; dateStamp: string } {
179
+ const iso = new Date().toISOString().replace(/[:-]|\.\d{3}/g, "");
180
+ return { amzDate: iso, dateStamp: iso.slice(0, 8) };
181
+ }
182
+
183
+ private canonicalUri(bucket: string, key: string): string {
184
+ return `${this.prefix}/${uriEncode(bucket, true)}/${uriEncode(key, true)}`;
185
+ }
186
+
187
+ private canonicalQuery(query: Record<string, string>): string {
188
+ return Object.keys(query)
189
+ .sort()
190
+ .map((k) => `${uriEncode(k, false)}=${uriEncode(query[k], false)}`)
191
+ .join("&");
192
+ }
193
+
194
+ private signingKey(dateStamp: string): Buffer {
195
+ const kDate = hmac(`AWS4${this.secretKey}`, dateStamp);
196
+ const kRegion = hmac(kDate, this.region);
197
+ const kService = hmac(kRegion, SERVICE);
198
+ return hmac(kService, "aws4_request");
199
+ }
200
+
201
+ private sign(canonicalRequest: string, amzDate: string, dateStamp: string, scope: string): string {
202
+ const stringToSign = [ALGORITHM, amzDate, scope, sha256Hex(canonicalRequest)].join("\n");
203
+ return createHmac("sha256", this.signingKey(dateStamp)).update(stringToSign, "utf8").digest("hex");
204
+ }
205
+
206
+ private async request(
207
+ method: string,
208
+ uri: string,
209
+ body: Buffer,
210
+ extraHeaders: Record<string, string> = {},
211
+ ): Promise<{ status: number; body: Buffer }> {
212
+ this.requireCredentials();
213
+ for (let attempt = 0; ; attempt++) {
214
+ const last = attempt === MAX_RETRIES;
215
+ let delay: number | null = null;
216
+ try {
217
+ // Signed afresh each attempt: x-amz-date moves on.
218
+ return await this.send(method, uri, body, extraHeaders);
219
+ } catch (e) {
220
+ if (e instanceof S3Error) {
221
+ if (last || !(e.status === 429 || e.status >= 500)) throw e;
222
+ delay = e.retryAfter;
223
+ } else {
224
+ // Connection refused or dropped, timeout: nothing was answered.
225
+ if (last) throw e;
226
+ delay = null;
227
+ }
228
+ }
229
+ if (delay == null) delay = Math.random() * (BACKOFF_BASE * 2 ** attempt);
230
+ await sleep(Math.min(delay, BACKOFF_CAP) * 1000);
231
+ }
232
+ }
233
+
234
+ private async send(
235
+ method: string,
236
+ uri: string,
237
+ body: Buffer,
238
+ extraHeaders: Record<string, string> = {},
239
+ ): Promise<{ status: number; body: Buffer }> {
240
+ const { amzDate, dateStamp } = this.now();
241
+ const payloadHash = sha256Hex(body);
242
+
243
+ const signed: Record<string, string> = {
244
+ host: this.host,
245
+ "x-amz-content-sha256": payloadHash,
246
+ "x-amz-date": amzDate,
247
+ };
248
+ const signedKeys = Object.keys(signed).sort();
249
+ const canonicalHeaders = signedKeys.map((k) => `${k}:${signed[k]}\n`).join("");
250
+ const signedHeaders = signedKeys.join(";");
251
+ const canonicalRequest = [method, uri, "", canonicalHeaders, signedHeaders, payloadHash].join("\n");
252
+ const scope = `${dateStamp}/${this.region}/${SERVICE}/aws4_request`;
253
+ const signature = this.sign(canonicalRequest, amzDate, dateStamp, scope);
254
+ const authorization = `${ALGORITHM} Credential=${this.accessKey}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
255
+
256
+ const headers: Record<string, string> = {
257
+ Authorization: authorization,
258
+ "x-amz-content-sha256": payloadHash,
259
+ "x-amz-date": amzDate,
260
+ ...extraHeaders,
261
+ };
262
+
263
+ const url = `${this.scheme}://${this.host}${uri}`;
264
+ const init: RequestInit = { method, headers, redirect: "manual" };
265
+ if (method === "PUT" || method === "POST") init.body = body;
266
+ if (AbortSignal.timeout) init.signal = AbortSignal.timeout(this.timeout);
267
+
268
+ const response = await fetch(url, init);
269
+ if (response.status < 200 || response.status >= 300) {
270
+ const err = new S3Error(response.status, `${method} ${uri}: HTTP ${response.status}`);
271
+ err.retryAfter = retryAfterSeconds(response.headers.get("retry-after"));
272
+ throw err;
273
+ }
274
+ return { status: response.status, body: Buffer.from(await response.arrayBuffer()) };
275
+ }
276
+ }
@@ -9,38 +9,15 @@ file, You can obtain one at https://mozilla.org/MPL/2.0/.
9
9
  import { randomBytes } from "node:crypto";
10
10
  import { mkdirSync, readFileSync, writeFileSync, unlinkSync, statSync } from "node:fs";
11
11
  import { resolve, sep } from "node:path";
12
- import { createRequire } from "node:module";
13
12
  import { Log } from "../../../core/src/index.js";
14
- import { optionalPackageMissing } from "../../../core/src/optionalPackage.js";
15
-
16
- // ESM has no global `require`; create one so the OPTIONAL aws-sdk can be loaded
17
- // synchronously in S3Storage's constructor.
18
- const require = createRequire(import.meta.url);
19
-
20
- /** S3 needs both SDK packages, so a missing one names the command for both. */
21
- const S3_PACKAGES = ["@aws-sdk/client-s3", "@aws-sdk/s3-request-presigner"];
22
-
23
- /**
24
- * Load an @aws-sdk package (an optional peer, ADR-0067). A missing one throws
25
- * the actionable install error; selectStorage() catches it and logs it while
26
- * degrading to local storage. Any OTHER load failure is re-thrown untouched,
27
- * so an installed-but-broken SDK is never mislabelled "not installed".
28
- */
29
- function loadAwsSdk(packageName: string): any {
30
- try {
31
- return require(packageName);
32
- } catch (error: any) {
33
- const missing = error?.code === "MODULE_NOT_FOUND" && String(error?.message ?? "").includes(`'${packageName}'`);
34
- if (!missing) throw error;
35
- throw optionalPackageMissing("S3Storage", S3_PACKAGES);
36
- }
37
- }
13
+ import { S3Client } from "./s3Client.js";
38
14
 
39
15
  /**
40
16
  * Pluggable file storage for the realtime "files" feature. StorageBackend is
41
17
  * the interface; LocalStorage (default, zero-dependency, filesystem) and
42
- * S3Storage (opt-in, S3-compatible via @aws-sdk/client-s3) are the shipped
43
- * implementations. Selection mirrors the cache/session/queue backend pattern:
18
+ * S3Storage (opt-in, S3-compatible, also zero-dependency — it uses Tina4's own
19
+ * stdlib SigV4 client, not @aws-sdk) are the shipped implementations.
20
+ * Selection mirrors the cache/session/queue backend pattern:
44
21
  * TINA4_STORAGE_BACKEND (local default | s3) plus per-backend env vars, with a
45
22
  * graceful fallback to local if an s3 backend cannot be constructed - a real
46
23
  * persistent store, never a silent no-op. Parity with Python's storage.py.
@@ -124,63 +101,46 @@ export class LocalStorage implements StorageBackend {
124
101
  }
125
102
 
126
103
  /**
127
- * S3-compatible store (AWS S3, MinIO, ...). Opt-in; the app installs the SDK:
128
- * `npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner`.
129
- * Presigned GET URLs let clients fetch large blobs straight from object
130
- * storage instead of streaming through the app.
104
+ * S3-compatible store (AWS S3, MinIO, Garage, ...). Opt-in, zero dependencies.
105
+ *
106
+ * Talks to S3 through Tina4's own stdlib SigV4 client (S3Client, node:crypto +
107
+ * global fetch) — no @aws-sdk packages. Presigned GET URLs let clients fetch
108
+ * large blobs straight from object storage instead of streaming through the app.
131
109
  */
132
110
  export class S3Storage implements StorageBackend {
133
- private client: any;
111
+ private client: S3Client;
134
112
  private bucket: string;
135
113
 
136
114
  constructor(opts: { endpoint?: string; key?: string; secret?: string; bucket?: string; region?: string } = {}) {
137
115
  this.bucket = opts.bucket || process.env.TINA4_STORAGE_BUCKET || "";
138
116
  if (!this.bucket) throw new Error("S3Storage requires TINA4_STORAGE_BUCKET");
139
- // Loaded lazily; a missing SDK throws the install command here, and
140
- // selectStorage() logs it and falls back to local storage.
141
- const { S3Client } = loadAwsSdk("@aws-sdk/client-s3");
142
- // url() needs the presigner; check it now so a half-installed SDK refuses
143
- // at construction instead of failing on the first download link.
144
- loadAwsSdk("@aws-sdk/s3-request-presigner");
145
- const endpoint = opts.endpoint || process.env.TINA4_STORAGE_URL;
146
117
  this.client = new S3Client({
147
- endpoint: endpoint || undefined,
148
- forcePathStyle: true,
118
+ endpoint: opts.endpoint || process.env.TINA4_STORAGE_URL || undefined,
149
119
  region: opts.region || process.env.TINA4_STORAGE_REGION || "us-east-1",
150
- credentials: {
151
- accessKeyId: opts.key || process.env.TINA4_STORAGE_KEY || "",
152
- secretAccessKey: opts.secret || process.env.TINA4_STORAGE_SECRET || "",
153
- },
120
+ accessKey: opts.key || process.env.TINA4_STORAGE_KEY || "",
121
+ secretKey: opts.secret || process.env.TINA4_STORAGE_SECRET || "",
154
122
  });
155
123
  }
156
124
 
157
125
  async put(key: string, data: Buffer | string, mime = "application/octet-stream"): Promise<void> {
158
- const { PutObjectCommand } = loadAwsSdk("@aws-sdk/client-s3");
159
- await this.client.send(new PutObjectCommand({ Bucket: this.bucket, Key: key, Body: data, ContentType: mime }));
126
+ await this.client.putObject(this.bucket, key, data, mime);
160
127
  }
161
128
 
162
129
  async get(key: string): Promise<Buffer | null> {
163
130
  try {
164
- const { GetObjectCommand } = loadAwsSdk("@aws-sdk/client-s3");
165
- const res = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key }));
166
- const chunks: Buffer[] = [];
167
- for await (const chunk of res.Body as AsyncIterable<Buffer>) chunks.push(Buffer.from(chunk));
168
- return Buffer.concat(chunks);
131
+ return await this.client.getObject(this.bucket, key);
169
132
  } catch {
170
133
  return null;
171
134
  }
172
135
  }
173
136
 
174
137
  async url(key: string, ttl = 3600): Promise<string | null> {
175
- const { GetObjectCommand } = loadAwsSdk("@aws-sdk/client-s3");
176
- const { getSignedUrl } = loadAwsSdk("@aws-sdk/s3-request-presigner");
177
- return getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.bucket, Key: key }), { expiresIn: ttl });
138
+ return this.client.presignedGetUrl(this.bucket, key, ttl);
178
139
  }
179
140
 
180
141
  async delete(key: string): Promise<void> {
181
142
  try {
182
- const { DeleteObjectCommand } = loadAwsSdk("@aws-sdk/client-s3");
183
- await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
143
+ await this.client.deleteObject(this.bucket, key);
184
144
  } catch (e) {
185
145
  Log.error(`S3Storage delete failed for ${key}: ${(e as Error).message}`);
186
146
  }
@@ -188,8 +148,7 @@ export class S3Storage implements StorageBackend {
188
148
 
189
149
  async exists(key: string): Promise<boolean> {
190
150
  try {
191
- const { HeadObjectCommand } = loadAwsSdk("@aws-sdk/client-s3");
192
- await this.client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));
151
+ await this.client.headObject(this.bucket, key);
193
152
  return true;
194
153
  } catch {
195
154
  return false;
@@ -200,7 +159,7 @@ export class S3Storage implements StorageBackend {
200
159
  /**
201
160
  * Resolve the storage backend from an explicit instance or the environment.
202
161
  * Falls back to LocalStorage (logging why) when an s3 backend cannot be built
203
- * (driver missing or config incomplete) - a real store, never a silent no-op.
162
+ * (config incomplete, e.g. no bucket) - a real store, never a silent no-op.
204
163
  */
205
164
  export function selectStorage(storage?: StorageBackend): StorageBackend {
206
165
  if (storage) return storage;