@galadrim/galadmin 0.2.9 → 0.2.11-alpha.1.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 (234) hide show
  1. package/.agents/report/blockers.json +1 -0
  2. package/.agents/report/failures/2026-09-23-api-user-filter-contract.md +31 -0
  3. package/.agents/report/failures/2026-09-23-api-values-contract.md +25 -0
  4. package/.agents/report/failures/2026-10-01-cycle-02-create-callback-contract.md +17 -0
  5. package/.agents/report/failures/2026-10-01-cycle-06-delete-callback-id-contract.md +17 -0
  6. package/.agents/report/failures/2026-10-01-cycle-07-panel-delete-callback-id-contract.md +24 -0
  7. package/.agents/report/failures/2026-10-01-cycle-13-update-callback-id-contract.md +17 -0
  8. package/.agents/report/failures/2026-10-01-cycle-15-rich-text-upload-boundary-contract.md +24 -0
  9. package/.agents/report/failures/2026-10-03-cycle-06-record-import-parser-contract.md +11 -0
  10. package/.agents/report/failures/2026-10-03-cycle-07-panel-import-parser-contract.md +24 -0
  11. package/.agents/report/failures/2026-10-07-resume-cycle-02-api-assembly-contract.md +46 -0
  12. package/.agents/report/port/2026-09-23-api-config-enrichment.md +13 -0
  13. package/.agents/report/port/2026-09-23-api-file-upload.md +11 -0
  14. package/.agents/report/port/2026-09-23-api-query-authorization.md +14 -0
  15. package/.agents/report/port/2026-09-23-api-request-configuration.md +11 -0
  16. package/.agents/report/port/2026-09-23-database-manager.md +13 -0
  17. package/.agents/report/port/2026-10-01-api-adjacent-record-id.md +9 -0
  18. package/.agents/report/port/2026-10-01-cycle-01-api-config-route.md +9 -0
  19. package/.agents/report/port/2026-10-01-cycle-03-custom-action.md +9 -0
  20. package/.agents/report/port/2026-10-01-cycle-04-custom-component.md +7 -0
  21. package/.agents/report/port/2026-10-01-cycle-05-custom-query.md +7 -0
  22. package/.agents/report/port/2026-10-01-cycle-08-bulk-delete.md +9 -0
  23. package/.agents/report/port/2026-10-01-cycle-09-duplicate-record.md +9 -0
  24. package/.agents/report/port/2026-10-01-cycle-10-notification-badges.md +9 -0
  25. package/.agents/report/port/2026-10-01-cycle-11-record.md +7 -0
  26. package/.agents/report/port/2026-10-01-cycle-12-search-companies.md +11 -0
  27. package/.agents/report/port/2026-10-01-cycle-14-upload-file.md +7 -0
  28. package/.agents/report/port/2026-10-03-cycle-01-create-record.md +11 -0
  29. package/.agents/report/port/2026-10-03-cycle-02-delete-record.md +11 -0
  30. package/.agents/report/port/2026-10-03-cycle-03-panel-delete-record.md +11 -0
  31. package/.agents/report/port/2026-10-03-cycle-04-update-record.md +11 -0
  32. package/.agents/report/port/2026-10-03-cycle-05-api-values.md +11 -0
  33. package/.agents/report/port/2026-10-06-cycle-01-record-import.md +9 -0
  34. package/.agents/report/port/2026-10-06-cycle-03-rich-text-upload.md +7 -0
  35. package/.agents/report/port/2026-10-06-cycle-04-user-filters.md +11 -0
  36. package/.agents/report/port/2026-10-06-resume-cycle-01-panel-import.md +9 -0
  37. package/.agents/report/port/2026-10-07-cycle-01-api-assembly.md +16 -0
  38. package/.agents/report/port/2026-10-07-cycle-02-index-entry-point.md +13 -0
  39. package/.agents/report/remediation/2026-10-02-api-user-filter-remediation.md +71 -0
  40. package/.agents/report/remediation/2026-10-02-api-values-remediation.md +17 -0
  41. package/.agents/report/remediation/2026-10-03-callback-id-contract-remediation.md +36 -0
  42. package/.agents/report/remediation/2026-10-06-import-parser-remediation.md +41 -0
  43. package/.agents/report/remediation/2026-10-06-rich-text-upload-remediation.md +33 -0
  44. package/.agents/report/remediation/2026-10-07-api-assembly-remediation.md +32 -0
  45. package/.agents/skills/port-javascript-to-typescript/SKILL.md +42 -0
  46. package/.agents/skills/port-javascript-to-typescript/agents/openai.yaml +3 -0
  47. package/.agents/skills/port-javascript-to-typescript/references/blockers.md +31 -0
  48. package/.agents/skills/port-javascript-to-typescript/references/planning.md +43 -0
  49. package/.agents/skills/port-javascript-to-typescript/references/porting.md +9 -0
  50. package/.agents/skills/port-javascript-to-typescript/references/reporting.md +24 -0
  51. package/.agents/skills/port-javascript-to-typescript/references/rules.md +8 -0
  52. package/.agents/skills/port-javascript-to-typescript/references/selection.md +16 -0
  53. package/.agents/skills/port-javascript-to-typescript/references/tools.md +20 -0
  54. package/.agents/skills/port-javascript-to-typescript/scripts/check.sh +17 -0
  55. package/.agents/skills/port-javascript-to-typescript/scripts/graph.sh +30 -0
  56. package/.agents/skills/port-javascript-to-typescript/scripts/madge.json +12 -0
  57. package/.agents/skills/port-javascript-to-typescript/scripts/oxlint.json +14 -0
  58. package/.agents/skills/port-javascript-to-typescript/scripts/test-tools.cjs +76 -0
  59. package/.github/actions/version-bump/action.yml +41 -0
  60. package/.github/workflows/alpha.yml +53 -0
  61. package/.github/workflows/ci.yml +36 -0
  62. package/.github/workflows/main.yml +14 -42
  63. package/AGENTS.md +5 -0
  64. package/README.md +41 -4
  65. package/dist/api/authorization.d.ts +38 -0
  66. package/dist/api/authorization.js +74 -0
  67. package/dist/api/authorization.js.map +1 -0
  68. package/dist/api/config.d.ts +42 -0
  69. package/dist/api/config.js +105 -0
  70. package/dist/api/config.js.map +1 -0
  71. package/dist/api/filterUser.d.ts +2 -0
  72. package/dist/api/filterUser.js +24 -0
  73. package/dist/api/filterUser.js.map +1 -0
  74. package/dist/api/importRecords.d.ts +1 -0
  75. package/dist/api/importRecords.js +16 -0
  76. package/dist/api/importRecords.js.map +1 -0
  77. package/dist/api/queries.d.ts +48 -0
  78. package/dist/api/queries.js +139 -0
  79. package/dist/api/queries.js.map +1 -0
  80. package/dist/api/request.d.ts +29 -0
  81. package/dist/api/request.js +3 -0
  82. package/dist/api/request.js.map +1 -0
  83. package/dist/api/routes/adjacentRecordId.d.ts +10 -0
  84. package/dist/api/routes/adjacentRecordId.js +13 -0
  85. package/dist/api/routes/adjacentRecordId.js.map +1 -0
  86. package/dist/api/routes/config.d.ts +3 -0
  87. package/dist/api/routes/config.js +27 -0
  88. package/dist/api/routes/config.js.map +1 -0
  89. package/dist/api/routes/createRecord.d.ts +19 -0
  90. package/dist/api/routes/createRecord.js +93 -0
  91. package/dist/api/routes/createRecord.js.map +1 -0
  92. package/dist/api/routes/customAction.d.ts +17 -0
  93. package/dist/api/routes/customAction.js +35 -0
  94. package/dist/api/routes/customAction.js.map +1 -0
  95. package/dist/api/routes/customComponent.d.ts +3 -0
  96. package/dist/api/routes/customComponent.js +34 -0
  97. package/dist/api/routes/customComponent.js.map +1 -0
  98. package/dist/api/routes/customQuery.d.ts +7 -0
  99. package/dist/api/routes/customQuery.js +33 -0
  100. package/dist/api/routes/customQuery.js.map +1 -0
  101. package/dist/api/routes/deleteRecord.d.ts +9 -0
  102. package/dist/api/routes/deleteRecord.js +46 -0
  103. package/dist/api/routes/deleteRecord.js.map +1 -0
  104. package/dist/api/routes/deleteRecordFromPanel.d.ts +10 -0
  105. package/dist/api/routes/deleteRecordFromPanel.js +36 -0
  106. package/dist/api/routes/deleteRecordFromPanel.js.map +1 -0
  107. package/dist/api/routes/deleteRecords.d.ts +9 -0
  108. package/dist/api/routes/deleteRecords.js +12 -0
  109. package/dist/api/routes/deleteRecords.js.map +1 -0
  110. package/dist/api/routes/duplicateRecord.d.ts +17 -0
  111. package/dist/api/routes/duplicateRecord.js +55 -0
  112. package/dist/api/routes/duplicateRecord.js.map +1 -0
  113. package/dist/api/routes/foreignSelectRecords.d.ts +10 -0
  114. package/dist/api/routes/foreignSelectRecords.js +61 -0
  115. package/dist/api/routes/foreignSelectRecords.js.map +1 -0
  116. package/dist/api/routes/notificationBadges.d.ts +6 -0
  117. package/dist/api/routes/notificationBadges.js +31 -0
  118. package/dist/api/routes/notificationBadges.js.map +1 -0
  119. package/dist/api/routes/record.d.ts +7 -0
  120. package/dist/api/routes/record.js +32 -0
  121. package/dist/api/routes/record.js.map +1 -0
  122. package/dist/api/routes/records.d.ts +14 -0
  123. package/dist/api/routes/records.js +57 -0
  124. package/dist/api/routes/records.js.map +1 -0
  125. package/dist/api/routes/recordsExport.d.ts +13 -0
  126. package/dist/api/routes/recordsExport.js +67 -0
  127. package/dist/api/routes/recordsExport.js.map +1 -0
  128. package/dist/api/routes/recordsImport.d.ts +4 -0
  129. package/dist/api/routes/recordsImport.js +100 -0
  130. package/dist/api/routes/recordsImport.js.map +1 -0
  131. package/dist/api/routes/searchCompanies.d.ts +17 -0
  132. package/dist/api/routes/searchCompanies.js +72 -0
  133. package/dist/api/routes/searchCompanies.js.map +1 -0
  134. package/dist/api/routes/subrecordsImport.d.ts +4 -0
  135. package/dist/api/routes/subrecordsImport.js +94 -0
  136. package/dist/api/routes/subrecordsImport.js.map +1 -0
  137. package/dist/api/routes/updateRecord.d.ts +13 -0
  138. package/dist/api/routes/updateRecord.js +55 -0
  139. package/dist/api/routes/updateRecord.js.map +1 -0
  140. package/dist/api/routes/uploadFile.d.ts +3 -0
  141. package/dist/api/routes/uploadFile.js +13 -0
  142. package/dist/api/routes/uploadFile.js.map +1 -0
  143. package/dist/api/routes/uploadFileFromBetterRichTextInput.d.ts +3 -0
  144. package/dist/api/routes/uploadFileFromBetterRichTextInput.js +33 -0
  145. package/dist/api/routes/uploadFileFromBetterRichTextInput.js.map +1 -0
  146. package/dist/api/uploadFile.d.ts +4 -0
  147. package/dist/api/uploadFile.js +209 -0
  148. package/dist/api/uploadFile.js.map +1 -0
  149. package/dist/api/values.d.ts +6 -0
  150. package/dist/api/values.js +88 -0
  151. package/dist/api/values.js.map +1 -0
  152. package/dist/api.d.ts +8 -27
  153. package/dist/api.js +80 -1389
  154. package/dist/api.js.map +1 -1
  155. package/dist/index.d.ts +2 -2
  156. package/dist/index.js +8 -2
  157. package/dist/index.js.map +1 -1
  158. package/dist/tsconfig.strict.tsbuildinfo +1 -0
  159. package/dist/types/config.d.ts +17 -7
  160. package/dist/types/config.js +4 -0
  161. package/dist/types/config.js.map +1 -1
  162. package/dist/types/database.d.ts +1 -0
  163. package/dist/types/database.js +3 -0
  164. package/dist/types/database.js.map +1 -0
  165. package/dist/utils/dbManager.d.ts +32 -7
  166. package/dist/utils/dbManager.js +59 -7
  167. package/dist/utils/dbManager.js.map +1 -1
  168. package/package.json +12 -2
  169. package/src/api/authorization.ts +129 -0
  170. package/src/api/config.ts +152 -0
  171. package/src/api/filterUser.ts +28 -0
  172. package/src/api/importRecords.ts +16 -0
  173. package/src/api/queries.ts +194 -0
  174. package/src/api/request.ts +31 -0
  175. package/src/api/routes/adjacentRecordId.ts +20 -0
  176. package/src/api/routes/config.ts +15 -0
  177. package/src/api/routes/createRecord.ts +131 -0
  178. package/src/api/routes/customAction.ts +60 -0
  179. package/src/api/routes/customComponent.ts +44 -0
  180. package/src/api/routes/customQuery.ts +53 -0
  181. package/src/api/routes/deleteRecord.ts +59 -0
  182. package/src/api/routes/deleteRecordFromPanel.ts +58 -0
  183. package/src/api/routes/deleteRecords.ts +28 -0
  184. package/src/api/routes/duplicateRecord.ts +73 -0
  185. package/src/api/routes/foreignSelectRecords.ts +80 -0
  186. package/src/api/routes/notificationBadges.ts +47 -0
  187. package/src/api/routes/record.ts +48 -0
  188. package/src/api/routes/records.ts +91 -0
  189. package/src/api/routes/recordsExport.ts +92 -0
  190. package/src/api/routes/recordsImport.ts +115 -0
  191. package/src/api/routes/searchCompanies.ts +96 -0
  192. package/src/api/routes/subrecordsImport.ts +114 -0
  193. package/src/api/routes/updateRecord.ts +74 -0
  194. package/src/api/routes/uploadFile.ts +16 -0
  195. package/src/api/routes/uploadFileFromBetterRichTextInput.ts +41 -0
  196. package/src/api/uploadFile.ts +249 -0
  197. package/src/api/values.ts +96 -0
  198. package/src/api.ts +94 -1578
  199. package/src/front/ckeditor5-build/node_modules/semver/bin/semver.js +0 -0
  200. package/src/index.ts +42 -25
  201. package/src/types/config.ts +17 -7
  202. package/src/types/database.ts +2 -0
  203. package/src/utils/dbManager.ts +99 -14
  204. package/tests/api-helpers.test.ts +59 -0
  205. package/tests/dbManager.test.ts +16 -1
  206. package/tests/helpers/fakeDb.ts +9 -7
  207. package/tests/integration/authorizationBoundary.integration.test.ts +57 -0
  208. package/tests/integration/configEnrichment.integration.test.ts +79 -0
  209. package/tests/integration/customComponentBoundary.integration.test.ts +58 -0
  210. package/tests/integration/dbManager.integration.test.ts +105 -3
  211. package/tests/integration/importParserChild.cjs +55 -0
  212. package/tests/integration/indexRuntime.integration.test.ts +22 -0
  213. package/tests/integration/indexRuntimeChild.cjs +110 -0
  214. package/tests/integration/queries.integration.test.ts +88 -0
  215. package/tests/integration/requestConfig.integration.test.ts +118 -0
  216. package/tests/integration/routes.authorization.integration.test.ts +1 -0
  217. package/tests/integration/routes.config.integration.test.ts +39 -1
  218. package/tests/integration/routes.crud.integration.test.ts +233 -1
  219. package/tests/integration/routes.custom.integration.test.ts +119 -0
  220. package/tests/integration/routes.foreign.integration.test.ts +3 -2
  221. package/tests/integration/routes.importExport.integration.test.ts +308 -6
  222. package/tests/integration/routes.integration.test.ts +4 -0
  223. package/tests/integration/routes.records.integration.test.ts +82 -6
  224. package/tests/integration/routes.upload.integration.test.ts +178 -0
  225. package/tests/integration/userFilters.integration.test.ts +225 -0
  226. package/tsconfig.json +1 -1
  227. package/tsconfig.strict.json +45 -0
  228. package/dist/front/ckeditor5-build/src/ckeditor.d.ts +0 -43
  229. package/dist/front/ckeditor5-build/src/ckeditor.js +0 -130
  230. package/dist/front/ckeditor5-build/src/ckeditor.js.map +0 -1
  231. package/dist/tsconfig.tsbuildinfo +0 -1
  232. package/src/front/ckeditor5-build/node_modules/.bin/semver +0 -12
  233. package/src/front/ckeditor5-build/node_modules/.bin/semver.cmd +0 -17
  234. package/src/front/ckeditor5-build/node_modules/.bin/semver.ps1 +0 -28
@@ -3,6 +3,7 @@ import * as os from "os";
3
3
  import * as path from "path";
4
4
  import * as request from "supertest";
5
5
  import { generateUniqueIdForObject } from "../../src/api";
6
+ import { uploadFile } from "../../src/api/uploadFile";
6
7
  import { buildAppForManager } from "../helpers/app";
7
8
  import { baseConfig, fruitView } from "../helpers/fixtures";
8
9
  import {
@@ -119,7 +120,49 @@ describeIntegration("upload routes against real databases", () => {
119
120
  contentType: "image/png",
120
121
  });
121
122
 
123
+ it.each([
124
+ { s3Config: { host: "s3.example.com", accessKeyId: "k", secretAccessKey: "s", bucket: "b" } },
125
+ { gcpConfig: { host: "storage.googleapis.com", bucket: "b" } },
126
+ { publicPath: "/srv/public", sftpConfig: { host: "h", user: "u", password: "p" } },
127
+ { publicPath: os.tmpdir(), subdirectory: "" },
128
+ ])("rejects a missing file for storage config %j", async (storage) => {
129
+ const { app, colId } = appWithColumn(fileColumn(storage));
130
+ const res = await request(app)
131
+ .post("/api/uploadFile")
132
+ .query({ viewIndex: 0, columnId: colId })
133
+ .field("description", "no file")
134
+ .expect(400);
135
+ expect(res.text).toBe("Missing upload file");
136
+ expect(jest.requireMock("@aws-sdk/client-s3").S3Client).not.toHaveBeenCalled();
137
+ expect(jest.requireMock("@google-cloud/storage").Storage).not.toHaveBeenCalled();
138
+ expect(jest.requireMock("ssh2-sftp-client").__sftp.connect).not.toHaveBeenCalled();
139
+ });
140
+
122
141
  describe("S3 backend", () => {
142
+ it.each(["view", "panel"])("finds %s upload columns and prefers view columns for duplicate IDs", async (location) => {
143
+ const sharedId = "upload-target";
144
+ const storage = (bucket: string) => ({
145
+ host: "s3.example.com", accessKeyId: "k", secretAccessKey: "s", bucket,
146
+ });
147
+ const mainColumn = fileColumn({
148
+ __id__: location === "view" ? sharedId : "different-view-column",
149
+ s3Config: storage("view-bucket"),
150
+ });
151
+ const panelColumn = fileColumn({ __id__: sharedId, s3Config: storage("panel-bucket") });
152
+ const instance = buildAppForManager(harness.manager, baseConfig({ views: [fruitView({
153
+ primaryId: "id", columns: [mainColumn],
154
+ recordViewPanels: [
155
+ { type: "table", label: "No creation", query: "SELECT * FROM fruits" },
156
+ { type: "table", label: "Creation", query: "SELECT * FROM fruits",
157
+ createConfig: { label: "Create", recordIdKey: "id", columns: [panelColumn] } },
158
+ ],
159
+ })] }));
160
+ await s3Request(instance, sharedId).expect(200);
161
+ expect(jest.requireMock("@aws-sdk/client-s3").PutObjectCommand).toHaveBeenCalledWith(
162
+ expect.objectContaining({ Bucket: `${location}-bucket` }),
163
+ );
164
+ });
165
+
123
166
  it("uploads with a public-read PutObjectCommand and returns the public URL", async () => {
124
167
  const s3Mock = jest.requireMock("@aws-sdk/client-s3");
125
168
  const column = fileColumn({
@@ -162,6 +205,83 @@ describeIntegration("upload routes against real databases", () => {
162
205
  expect(Buffer.isBuffer(sentCommand.Body)).toBe(true);
163
206
  });
164
207
 
208
+ it("lets the SDK resolve credentials when no accessKeyId is configured", async () => {
209
+ const s3Mock = jest.requireMock("@aws-sdk/client-s3");
210
+ const column = fileColumn({
211
+ s3Config: {
212
+ host: "s3.example.com",
213
+ bucket: "b",
214
+ },
215
+ });
216
+ const { app, colId } = appWithColumn(column);
217
+ await s3Request(app, colId).expect(200);
218
+ expect(s3Mock.S3Client.mock.calls[0][0]).not.toHaveProperty(
219
+ "credentials",
220
+ );
221
+ });
222
+
223
+ it("lets the SDK resolve credentials when accessKeyId is an empty string", async () => {
224
+ const s3Mock = jest.requireMock("@aws-sdk/client-s3");
225
+ const column = fileColumn({
226
+ s3Config: {
227
+ host: "s3.example.com",
228
+ accessKeyId: "",
229
+ secretAccessKey: "",
230
+ bucket: "b",
231
+ },
232
+ });
233
+ const { app, colId } = appWithColumn(column);
234
+ await s3Request(app, colId).expect(200);
235
+ expect(s3Mock.S3Client.mock.calls[0][0]).not.toHaveProperty(
236
+ "credentials",
237
+ );
238
+ });
239
+
240
+ it("lets the SDK resolve credentials in the signed-URL flow when no accessKeyId is configured", async () => {
241
+ const presignerMock = jest.requireMock("@aws-sdk/s3-request-presigner");
242
+ const s3Mock = jest.requireMock("@aws-sdk/client-s3");
243
+ const column = fileColumn({
244
+ s3Config: {
245
+ host: "s3.example.com",
246
+ bucket: "b",
247
+ signedUrlNeeded: true,
248
+ region: "eu-west-3",
249
+ },
250
+ });
251
+ const { app, colId } = appWithColumn(column);
252
+ await s3Request(app, colId).expect(200);
253
+ expect(s3Mock.S3Client.mock.calls[0][0]).not.toHaveProperty(
254
+ "credentials",
255
+ );
256
+ expect(presignerMock.getSignedUrl).toHaveBeenCalledWith(
257
+ s3Mock.S3Client.mock.results[0].value,
258
+ expect.anything(),
259
+ { expiresIn: 150 },
260
+ );
261
+ });
262
+
263
+ it.each([
264
+ ["document", Buffer.from("text"), /^document-\d+\.undefined$/, "application/octet-stream"],
265
+ ["broken.png", Buffer.from("not an image"), /^broken-\d+\.png$/, "image/png"],
266
+ ["empty.txt", Buffer.alloc(0), /^empty-\d+\.txt$/, "text/plain"],
267
+ ["photo.PNG", PNG_BUFFER, /^photo-\d+\.PNG$/, "image/png"],
268
+ ])("preserves filename and image handling for %s", async (filename, content, expectedPath, contentType) => {
269
+ const { app, colId } = appWithColumn(fileColumn({
270
+ s3Config: { host: "s3.example.com", accessKeyId: "k", secretAccessKey: "s", bucket: "b" },
271
+ }));
272
+ const res = await request(app)
273
+ .post("/api/uploadFile")
274
+ .query({ viewIndex: 0, columnId: colId })
275
+ .attach("file", content, { filename })
276
+ .expect(200);
277
+ expect(res.body.originalFilename).toBe(filename);
278
+ expect(res.body.path).toMatch(expectedPath);
279
+ expect(res.body.aspectRatio).toBeNull();
280
+ expect(jest.requireMock("@aws-sdk/client-s3").PutObjectCommand).toHaveBeenCalledWith(
281
+ expect.objectContaining({ ContentType: contentType, Body: content }),
282
+ );
283
+ });
284
+
165
285
  it("prefixes https:// to hosts missing a scheme", async () => {
166
286
  const s3Mock = jest.requireMock("@aws-sdk/client-s3");
167
287
  const column = fileColumn({
@@ -303,6 +423,18 @@ describeIntegration("upload routes against real databases", () => {
303
423
  expect(res.body.aspectRatio).toBe(1); // PNG_BUFFER is a real 1x1 PNG header
304
424
  });
305
425
 
426
+ it("prefers GCP when all remote storage settings are present", async () => {
427
+ const { app, colId } = appWithColumn(fileColumn({
428
+ s3Config: { host: "s3.example.com", accessKeyId: "k", secretAccessKey: "s", bucket: "s3" },
429
+ gcpConfig: { host: "storage.googleapis.com", bucket: "gcp" },
430
+ sftpConfig: { host: "sftp.example.com", user: "u", password: "p" },
431
+ }));
432
+ await s3Request(app, colId).expect(200);
433
+ expect(jest.requireMock("@google-cloud/storage").__bucket).toHaveBeenCalledWith("gcp");
434
+ expect(jest.requireMock("@aws-sdk/client-s3").S3Client).not.toHaveBeenCalled();
435
+ expect(jest.requireMock("ssh2-sftp-client").__sftp.connect).not.toHaveBeenCalled();
436
+ });
437
+
306
438
  it("returns 500 when the upload fails", async () => {
307
439
  const gcpMock = jest.requireMock("@google-cloud/storage");
308
440
  gcpMock.__upload.mockRejectedValueOnce(new Error("gcp down"));
@@ -324,6 +456,30 @@ describeIntegration("upload routes against real databases", () => {
324
456
  expect(res.text).toContain("publicPath");
325
457
  });
326
458
 
459
+ it("reads the current SFTP config once per credential inside the callback", async () => {
460
+ const column = fileColumn({ publicPath: "/srv/public" });
461
+ const { app } = appWithColumn(column);
462
+ let reads = 0;
463
+ Object.defineProperty(column, "sftpConfig", {
464
+ get: () => {
465
+ reads += 1;
466
+ return { host: `host-${reads}`, user: `user-${reads}`, password: `password-${reads}` };
467
+ },
468
+ });
469
+ app.post("/upload-read-order", (req, res) => {
470
+ uploadFile(column, "file", req, res);
471
+ reads = 0;
472
+ });
473
+ await request(app)
474
+ .post("/upload-read-order")
475
+ .attach("file", PNG_BUFFER, { filename: "photo.png" })
476
+ .expect(200);
477
+ expect(reads).toBe(3);
478
+ expect(jest.requireMock("ssh2-sftp-client").__sftp.connect).toHaveBeenCalledWith({
479
+ host: "host-1", port: 22, username: "user-2", password: "password-3",
480
+ });
481
+ });
482
+
327
483
  it("fastPuts the temp file to the remote path and cleans it up", async () => {
328
484
  const sftpMock = jest.requireMock("ssh2-sftp-client");
329
485
  const column = fileColumn({
@@ -380,6 +536,18 @@ describeIntegration("upload routes against real databases", () => {
380
536
  expect(fs.existsSync(stored)).toBe(true);
381
537
  });
382
538
 
539
+ it("uses the default temporary directory when both path properties are absent", async () => {
540
+ const { app, colId } = appWithColumn(fileColumn());
541
+ const res = await s3Request(app, colId).expect(200);
542
+ try {
543
+ expect(path.dirname(res.body.path)).toBe(os.tmpdir());
544
+ expect(res.body.originalFilename).toBe("photo.png");
545
+ expect(fs.readFileSync(res.body.path)).toEqual(PNG_BUFFER);
546
+ } finally {
547
+ fs.rmSync(res.body.path, { force: true });
548
+ }
549
+ });
550
+
383
551
  it("fails without a subdirectory (publicPath + undefined concat — documented quirk)", async () => {
384
552
  // uploadDir becomes "<publicPath>undefined", which never exists, so
385
553
  // the multipart parse fails with 500. A subdirectory is effectively
@@ -500,6 +668,16 @@ describeIntegration("upload routes against real databases", () => {
500
668
  expectNoStorage();
501
669
  });
502
670
 
671
+ it("rejects a role-restricted panel column when the session has no user", async () => {
672
+ const column = richColumn({ roles: ["editor"] });
673
+ const app = buildAppForManager(harness.manager, baseConfig({ views: [fruitView({
674
+ recordViewPanels: [{ type: "table", label: "Notes", query: "SELECT * FROM fruit_notes",
675
+ createConfig: { tableName: "fruit_notes", recordIdKey: "fruitId", columns: [column] } }],
676
+ })] }), null);
677
+ await upload(app, { viewIndex: 0, columnId: generateUniqueIdForObject(column) }).expect(403);
678
+ expectNoStorage();
679
+ });
680
+
503
681
  it("uses indexes from the role-filtered views", async () => {
504
682
  const column = richColumn();
505
683
  const app = buildAppForManager(harness.manager, baseConfig({ views: [
@@ -0,0 +1,225 @@
1
+ import * as express from "express";
2
+ import * as request from "supertest";
3
+ import type { RequestHandler } from "express";
4
+ import createRecordQueries from "../../src/api/queries";
5
+ import recordsRoute from "../../src/api/routes/records";
6
+ import exportRoute from "../../src/api/routes/recordsExport";
7
+ import foreignRoute from "../../src/api/routes/foreignSelectRecords";
8
+ import { baseConfig, fruitView } from "../helpers/fixtures";
9
+ import { buildAppForManager } from "../helpers/app";
10
+ import { DbHarness, describeIntegration, getMysqlHarness, getMysql2Harness, getPgHarness, resetTables } from "./dbHarness";
11
+
12
+ type CallbackUser = { role: string; userId?: string | number };
13
+ type Filter = (user: CallbackUser) => string;
14
+ type RouteName = "records" | "recordsExport" | "foreignSelectRecords";
15
+
16
+ // The forwarding recorder below runs the real query helper and database. It
17
+ // observes whether a data query was invoked; it does not replace query results.
18
+ describeIntegration("configured user filters against real databases", () => {
19
+ let harnesses: DbHarness[];
20
+ beforeAll(async () => { harnesses = [await getMysqlHarness(), await getMysql2Harness(), await getPgHarness()]; });
21
+ afterAll(async () => { for (const harness of harnesses || []) await harness.stop(); });
22
+
23
+ describe.each([0, 1, 2])("backend %s", (backend) => {
24
+ let harness: DbHarness;
25
+ beforeEach(async () => { harness = harnesses[backend]; await resetTables(harness); });
26
+
27
+ const literal = (value: string | number) => "'" + (harness.dbType === "POSTGRESQL"
28
+ ? String(value).replace(/'/g, "''")
29
+ : String(value).replace(/\\/g, "\\\\").replace(/'/g, "\\'")) + "'";
30
+ const scoped = (user: CallbackUser) => user.role === "root" ? ""
31
+ : user.userId === undefined ? "1 = 0" : `slug = ${literal(user.userId)}`;
32
+
33
+ const seed = async (id: string | number) => {
34
+ for (const row of [
35
+ ["keep mine", String(id), 1], ["keep other", "someone-else", 1],
36
+ ["blocked mine", String(id), 1], ["keep inactive", String(id), 0],
37
+ ]) {
38
+ const { err } = await harness.query("INSERT INTO fruits (fruitName, slug, acidity) VALUES (?, ?, ?)", row);
39
+ expect(err).toBeNull();
40
+ }
41
+ };
42
+
43
+ const build = (route: RouteName, user: unknown, filter?: Filter, events: string[] = []) => {
44
+ const column = {
45
+ __id__: "foreign", label: "Name", dataType: "foreign", name: "fruitName",
46
+ table: "fruits", foreignName: "id", searchableName: "fruitName", filters: "acidity > 0",
47
+ };
48
+ const view = fruitView({
49
+ tableName: "fruits", primaryId: "id", filters: "acidity > 0", columns: [column],
50
+ });
51
+ const target = route === "foreignSelectRecords" ? column : view;
52
+ if (filter) Object.defineProperty(target, "filtersFromUser", { value: filter, configurable: true });
53
+ const config = baseConfig({ enriched: true, views: [view] });
54
+ const session = { user };
55
+ const actual = createRecordQueries(harness.manager).getRecords;
56
+ let queries = 0;
57
+ const getRecords: typeof actual = async (...args) => {
58
+ queries++;
59
+ events.push("query");
60
+ return actual(...args);
61
+ };
62
+ const handler = route === "records" ? recordsRoute(getRecords)
63
+ : route === "recordsExport" ? exportRoute(getRecords)
64
+ : foreignRoute(harness.manager, getRecords);
65
+ const errors: unknown[] = [];
66
+ const app = express();
67
+ app.use(express.json());
68
+ app.use((req, _res, next) => {
69
+ req.config = config;
70
+ Object.defineProperty(req, "session", { value: session });
71
+ next();
72
+ });
73
+ const observe: RequestHandler = async (req, res, next) => {
74
+ try { await handler(req, res, next); }
75
+ catch (error) { errors.push(error); res.sendStatus(599); }
76
+ };
77
+ app.post("/filter", observe);
78
+ const send = () => request(app).post("/filter").send({
79
+ viewIndex: 0, columnId: "foreign", page: 1,
80
+ filters: route === "records" ? ["fruitName LIKE 'keep%'"] : "fruitName LIKE 'keep%'",
81
+ ...(route === "foreignSelectRecords" ? { query: "keep" } : {}),
82
+ });
83
+ return { target, session, errors, send, queries: () => queries };
84
+ };
85
+
86
+ const names = (route: RouteName, body: { records?: { fruitName: string }[]; content?: string } | { label: string }[]) => {
87
+ if (Array.isArray(body)) return body.map((row) => row.label);
88
+ return route === "records" ? body.records?.map((row) => row.fruitName) : body.content?.split("\r\n").slice(1);
89
+ };
90
+
91
+ describe.each<RouteName>(["records", "recordsExport", "foreignSelectRecords"])("route %s", (route) => {
92
+ it.each([
93
+ ["number", 2], ["zero", 0], ["numeric string", "002"],
94
+ ["UUID", "00000000-0000-4000-8000-000000000001"], ["SQL punctuation", "owner' OR '1'='1"],
95
+ ])("FILTER USER POLICY: scopes an exact %s ID and keeps the session", async (_label, id) => {
96
+ await seed(id);
97
+ const user = Object.freeze({ role: "editor", userId: id, extra: "stored-only" });
98
+ const received: unknown[] = [];
99
+ const instance = build(route, user, function (input) {
100
+ expect(this).toBe(instance.target);
101
+ expect(arguments).toHaveLength(1);
102
+ received.push(input);
103
+ return scoped(input);
104
+ });
105
+ const response = await instance.send();
106
+ expect(response.status).toBe(200);
107
+ expect(received).toEqual([{ role: "editor", userId: id }]);
108
+ expect(received[0]).not.toBe(user);
109
+ expect(instance.session.user).toBe(user);
110
+ expect(names(route, response.body)).toEqual(["keep mine"]);
111
+ expect(instance.queries()).toBe(1);
112
+ });
113
+
114
+ it("FILTER USER POLICY: invokes root filters without reading or forwarding an ID", async () => {
115
+ await seed(2);
116
+ let idReads = 0;
117
+ const user = { role: "root", get userId() { idReads++; throw new Error("root ID read"); } };
118
+ let calls = 0;
119
+ const instance = build(route, user, function (input) {
120
+ expect(this).toBe(instance.target);
121
+ expect(arguments).toHaveLength(1);
122
+ expect(input).toEqual({ role: "root" });
123
+ calls++;
124
+ return "fruitName = 'keep other'";
125
+ });
126
+ const response = await instance.send();
127
+ expect(response.status).toBe(200);
128
+ expect(names(route, response.body)).toEqual(["keep other"]);
129
+ expect({ calls, idReads, queries: instance.queries() }).toEqual({ calls: 1, idReads: 0, queries: 1 });
130
+ expect(instance.session.user).toBe(user);
131
+ });
132
+
133
+ it.each([{}, { userId: undefined }])("FILTER USER POLICY: lets a regular user without an ID choose a role policy %j", async (idFields) => {
134
+ await seed(2);
135
+ const user = Object.freeze({ role: "editor", ...idFields });
136
+ let calls = 0;
137
+ const instance = build(route, user, (input) => {
138
+ expect(input).toEqual({ role: "editor" });
139
+ expect(Object.prototype.hasOwnProperty.call(input, "userId")).toBe(false);
140
+ calls++;
141
+ return "fruitName = 'keep mine'";
142
+ });
143
+ const response = await instance.send();
144
+ expect(response.status).toBe(200);
145
+ expect(names(route, response.body)).toEqual(["keep mine"]);
146
+ expect(calls).toBe(1);
147
+ expect(instance.session.user).toBe(user);
148
+ });
149
+
150
+ it("FILTER USER POLICY: accepts unnormalized numeric values and empty identifiers", async () => {
151
+ for (const id of [-2.5, NaN, Infinity, ""]) {
152
+ let received: unknown;
153
+ const instance = build(route, { role: "", userId: id }, (input) => {
154
+ received = input;
155
+ return "1 = 0";
156
+ });
157
+ await instance.send().expect(200);
158
+ expect(received).toEqual({ role: "", userId: id });
159
+ }
160
+ });
161
+
162
+ it("FILTER USER POLICY: rejects malformed sessions before callback or data query", async () => {
163
+ const invalid: unknown[] = [undefined, null, [], "editor", 7, false, () => {}, {},
164
+ { role: null }, { role: 1 }, { role: [] },
165
+ ...[null, true, [], {}, BigInt(1), Symbol("id"), () => {}].map((userId) => ({ role: "editor", userId }))];
166
+ for (const user of invalid) {
167
+ let calls = 0;
168
+ const instance = build(route, user, () => { calls++; return ""; });
169
+ const response = await instance.send();
170
+ expect({ status: response.status, calls, queries: instance.queries(), errors: instance.errors }).toEqual({
171
+ status: 401, calls: 0, queries: 0, errors: [],
172
+ });
173
+ expect(instance.session.user).toBe(user);
174
+ }
175
+ });
176
+
177
+ it("does not read or validate the session when no user filter is configured", async () => {
178
+ await seed(2);
179
+ const instance = build(route, null);
180
+ Object.defineProperty(instance.session, "user", { get() { throw new Error("unexpected session read"); } });
181
+ const response = await instance.send().expect(200);
182
+ expect(names(route, response.body)).toEqual(["keep mine", "keep other"]);
183
+ expect(instance.queries()).toBe(1);
184
+ });
185
+
186
+ it("preserves callback receiver, count, thrown error and no-query behavior", async () => {
187
+ const failure = new Error("filter failure");
188
+ let calls = 0;
189
+ const instance = build(route, { role: "editor", userId: 2 }, function () {
190
+ expect(this).toBe(instance.target);
191
+ calls++;
192
+ throw failure;
193
+ });
194
+ await instance.send().expect(599);
195
+ expect(instance.errors).toEqual([failure]);
196
+ expect(instance.errors[0]).toBe(failure);
197
+ expect(calls).toBe(1);
198
+ expect(instance.queries()).toBe(0);
199
+ });
200
+
201
+ it("preserves table/filter/callback property evaluation order", async () => {
202
+ const events: string[] = [];
203
+ const filter = function () { events.push("callback"); return "1 = 0"; };
204
+ const instance = build(route, { role: "editor", userId: 2 }, filter, events);
205
+ Object.defineProperty(instance.target, route === "foreignSelectRecords" ? "table" : "tableName", {
206
+ get() { events.push("table"); return "fruits"; },
207
+ });
208
+ Object.defineProperty(instance.target, "filters", { get() { events.push("filters"); return "acidity > 0"; } });
209
+ Object.defineProperty(instance.target, "filtersFromUser", { get() { events.push("filter property"); return filter; } });
210
+ await instance.send().expect(200);
211
+ expect(events).toEqual(["table", "filters", "filter property", "filter property", "callback", "query"]);
212
+ });
213
+ });
214
+
215
+ it("keeps API request-filter authorization before a configured user filter", async () => {
216
+ let calls = 0;
217
+ const app = buildAppForManager(harness.manager, baseConfig({ views: [fruitView({
218
+ filtersFromUser: () => { calls++; return ""; },
219
+ })] }), null);
220
+ await request(app).post("/api/records").send({ viewIndex: 0, page: 1, filters: ["1=1; DROP TABLE fruits"] }).expect(400);
221
+ expect(calls).toBe(0);
222
+ expect((await harness.query("SELECT * FROM fruits")).rows).toEqual([]);
223
+ });
224
+ });
225
+ });
package/tsconfig.json CHANGED
@@ -17,4 +17,4 @@
17
17
  "include": [
18
18
  "src/"
19
19
  ]
20
- }
20
+ }
@@ -0,0 +1,45 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "strict": true
5
+ },
6
+ "include": [
7
+ "src/types/**/*.ts",
8
+ "src/utils/imageDimensions.ts",
9
+ "src/utils/dbManager.ts",
10
+ "src/api/config.ts",
11
+ "src/api/queries.ts",
12
+ "src/api/authorization.ts",
13
+ "src/api/uploadFile.ts",
14
+ "src/api/request.ts",
15
+ "src/api/routes/adjacentRecordId.ts",
16
+ "src/api/routes/config.ts",
17
+ "src/api/routes/customAction.ts",
18
+ "src/api/routes/customComponent.ts",
19
+ "src/api/routes/customQuery.ts",
20
+ "src/api/routes/deleteRecords.ts",
21
+ "src/api/routes/duplicateRecord.ts",
22
+ "src/api/routes/notificationBadges.ts",
23
+ "src/api/routes/record.ts",
24
+ "src/api/routes/searchCompanies.ts",
25
+ "src/api/routes/uploadFile.ts",
26
+ "src/api/routes/createRecord.ts",
27
+ "src/api/routes/deleteRecord.ts",
28
+ "src/api/routes/deleteRecordFromPanel.ts",
29
+ "src/api/routes/updateRecord.ts",
30
+ "src/api/values.ts",
31
+ "src/api/importRecords.ts",
32
+ "src/api/routes/recordsImport.ts",
33
+ "src/api/routes/subrecordsImport.ts",
34
+ "src/api/routes/uploadFileFromBetterRichTextInput.ts",
35
+ "src/api/filterUser.ts",
36
+ "src/api/routes/records.ts",
37
+ "src/api/routes/recordsExport.ts",
38
+ "src/api/routes/foreignSelectRecords.ts",
39
+ "src/api.ts",
40
+ "src/index.ts"
41
+ ],
42
+ "exclude": [
43
+ "src/front"
44
+ ]
45
+ }
@@ -1,43 +0,0 @@
1
- import { ClassicEditor } from '@ckeditor/ckeditor5-editor-classic';
2
- import { Alignment } from '@ckeditor/ckeditor5-alignment';
3
- import { Autoformat } from '@ckeditor/ckeditor5-autoformat';
4
- import { Bold, Italic, Strikethrough, Underline } from '@ckeditor/ckeditor5-basic-styles';
5
- import { BlockQuote } from '@ckeditor/ckeditor5-block-quote';
6
- import { CloudServices } from '@ckeditor/ckeditor5-cloud-services';
7
- import { Essentials } from '@ckeditor/ckeditor5-essentials';
8
- import { FindAndReplace } from '@ckeditor/ckeditor5-find-and-replace';
9
- import { FontBackgroundColor, FontColor } from '@ckeditor/ckeditor5-font';
10
- import { Heading } from '@ckeditor/ckeditor5-heading';
11
- import { Highlight } from '@ckeditor/ckeditor5-highlight';
12
- import { HorizontalLine } from '@ckeditor/ckeditor5-horizontal-line';
13
- import { HtmlEmbed } from '@ckeditor/ckeditor5-html-embed';
14
- import { AutoImage, Image, ImageCaption, ImageInsert, ImageResize, ImageStyle, ImageToolbar, ImageUpload } from '@ckeditor/ckeditor5-image';
15
- import { Indent, IndentBlock } from '@ckeditor/ckeditor5-indent';
16
- import { AutoLink, Link, LinkImage } from '@ckeditor/ckeditor5-link';
17
- import { List } from '@ckeditor/ckeditor5-list';
18
- import { MediaEmbed } from '@ckeditor/ckeditor5-media-embed';
19
- import { Paragraph } from '@ckeditor/ckeditor5-paragraph';
20
- import { PasteFromOffice } from '@ckeditor/ckeditor5-paste-from-office';
21
- import { RemoveFormat } from '@ckeditor/ckeditor5-remove-format';
22
- import { SelectAll } from '@ckeditor/ckeditor5-select-all';
23
- import { SourceEditing } from '@ckeditor/ckeditor5-source-editing';
24
- import { Table, TableToolbar } from '@ckeditor/ckeditor5-table';
25
- import { TextTransformation } from '@ckeditor/ckeditor5-typing';
26
- import { SimpleUploadAdapter } from '@ckeditor/ckeditor5-upload';
27
- import { WordCount } from '@ckeditor/ckeditor5-word-count';
28
- declare class Editor extends ClassicEditor {
29
- static builtinPlugins: (typeof Alignment | typeof AutoImage | typeof AutoLink | typeof Autoformat | typeof BlockQuote | typeof Bold | typeof CloudServices | typeof Essentials | typeof FindAndReplace | typeof FontBackgroundColor | typeof FontColor | typeof Heading | typeof Highlight | typeof HorizontalLine | typeof HtmlEmbed | typeof Image | typeof ImageCaption | typeof ImageInsert | typeof ImageResize | typeof ImageStyle | typeof ImageToolbar | typeof ImageUpload | typeof Indent | typeof IndentBlock | typeof Italic | typeof Link | typeof LinkImage | typeof List | typeof MediaEmbed | typeof Paragraph | typeof PasteFromOffice | typeof RemoveFormat | typeof SelectAll | typeof SimpleUploadAdapter | typeof SourceEditing | typeof Strikethrough | typeof Table | typeof TableToolbar | typeof TextTransformation | typeof Underline | typeof WordCount)[];
30
- static defaultConfig: {
31
- toolbar: {
32
- items: string[];
33
- };
34
- language: string;
35
- image: {
36
- toolbar: string[];
37
- };
38
- table: {
39
- contentToolbar: string[];
40
- };
41
- };
42
- }
43
- export default Editor;