@open-mercato/core 0.10.1-develop.7728.1.6b52eb2849 → 0.10.1-develop.7741.1.61a2652671

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 (70) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/api_key/index.js +2 -0
  3. package/dist/generated/entities/api_key/index.js.map +2 -2
  4. package/dist/generated/entity-fields-registry.js +1 -0
  5. package/dist/generated/entity-fields-registry.js.map +2 -2
  6. package/dist/modules/api_keys/data/entities.js +7 -0
  7. package/dist/modules/api_keys/data/entities.js.map +2 -2
  8. package/dist/modules/api_keys/migrations/Migration20261012202110_api_keys.js +30 -0
  9. package/dist/modules/api_keys/migrations/Migration20261012202110_api_keys.js.map +7 -0
  10. package/dist/modules/api_keys/services/apiKeyService.js +31 -9
  11. package/dist/modules/api_keys/services/apiKeyService.js.map +2 -2
  12. package/dist/modules/attachments/lib/ocrService.js +8 -0
  13. package/dist/modules/attachments/lib/ocrService.js.map +2 -2
  14. package/dist/modules/auth/api/logout.js +3 -1
  15. package/dist/modules/auth/api/logout.js.map +2 -2
  16. package/dist/modules/sales/commands/documents.js +1190 -1408
  17. package/dist/modules/sales/commands/documents.js.map +2 -2
  18. package/dist/modules/sales/commands/returns.js +34 -11
  19. package/dist/modules/sales/commands/returns.js.map +2 -2
  20. package/dist/modules/sales/commands/shared.js +5 -0
  21. package/dist/modules/sales/commands/shared.js.map +2 -2
  22. package/dist/modules/sales/commands/shipments.js +9 -0
  23. package/dist/modules/sales/commands/shipments.js.map +2 -2
  24. package/dist/modules/staff/api/timesheets/time-projects/route.js +6 -2
  25. package/dist/modules/staff/api/timesheets/time-projects/route.js.map +2 -2
  26. package/dist/modules/workflows/api/portal/tasks/[id]/complete/route.js +1 -1
  27. package/dist/modules/workflows/api/portal/tasks/[id]/complete/route.js.map +2 -2
  28. package/dist/modules/workflows/api/tasks/[id]/complete/route.js +7 -1
  29. package/dist/modules/workflows/api/tasks/[id]/complete/route.js.map +2 -2
  30. package/dist/modules/workflows/backend/tasks/[id]/page.js +2 -1
  31. package/dist/modules/workflows/backend/tasks/[id]/page.js.map +2 -2
  32. package/dist/modules/workflows/lib/instance-status.js +16 -0
  33. package/dist/modules/workflows/lib/instance-status.js.map +7 -0
  34. package/dist/modules/workflows/lib/task-handler.js +16 -8
  35. package/dist/modules/workflows/lib/task-handler.js.map +2 -2
  36. package/dist/modules/workflows/lib/task-sla.js +10 -0
  37. package/dist/modules/workflows/lib/task-sla.js.map +2 -2
  38. package/dist/modules/workflows/lib/workflow-executor.js +29 -1
  39. package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
  40. package/generated/entities/api_key/index.ts +1 -0
  41. package/generated/entity-fields-registry.ts +1 -0
  42. package/package.json +7 -7
  43. package/src/modules/api_keys/data/entities.ts +25 -1
  44. package/src/modules/api_keys/migrations/.snapshot-open-mercato.json +234 -68
  45. package/src/modules/api_keys/migrations/Migration20261012202110_api_keys.ts +47 -0
  46. package/src/modules/api_keys/services/apiKeyService.ts +55 -9
  47. package/src/modules/attachments/lib/ocrService.ts +8 -0
  48. package/src/modules/auth/api/logout.ts +3 -1
  49. package/src/modules/auth/i18n/de.json +3 -0
  50. package/src/modules/auth/i18n/en.json +3 -0
  51. package/src/modules/auth/i18n/es.json +3 -0
  52. package/src/modules/auth/i18n/ko.json +3 -0
  53. package/src/modules/auth/i18n/pl.json +3 -0
  54. package/src/modules/sales/commands/documents.ts +1501 -1626
  55. package/src/modules/sales/commands/returns.ts +41 -14
  56. package/src/modules/sales/commands/shared.ts +40 -0
  57. package/src/modules/sales/commands/shipments.ts +10 -0
  58. package/src/modules/staff/api/timesheets/time-projects/route.ts +6 -2
  59. package/src/modules/workflows/api/portal/tasks/[id]/complete/route.ts +5 -1
  60. package/src/modules/workflows/api/tasks/[id]/complete/route.ts +7 -1
  61. package/src/modules/workflows/backend/tasks/[id]/page.tsx +6 -2
  62. package/src/modules/workflows/i18n/de.json +2 -0
  63. package/src/modules/workflows/i18n/en.json +2 -0
  64. package/src/modules/workflows/i18n/es.json +2 -0
  65. package/src/modules/workflows/i18n/ko.json +2 -0
  66. package/src/modules/workflows/i18n/pl.json +2 -0
  67. package/src/modules/workflows/lib/instance-status.ts +29 -0
  68. package/src/modules/workflows/lib/task-handler.ts +18 -10
  69. package/src/modules/workflows/lib/task-sla.ts +13 -0
  70. package/src/modules/workflows/lib/workflow-executor.ts +44 -1
@@ -1,29 +1,63 @@
1
1
  {
2
+ "name": "public",
2
3
  "namespaces": [
3
4
  "public"
4
5
  ],
5
- "name": "public",
6
6
  "tables": [
7
7
  {
8
+ "name": "api_keys",
9
+ "schema": "public",
8
10
  "columns": {
9
- "id": {
10
- "name": "id",
11
- "type": "uuid",
11
+ "created_at": {
12
+ "name": "created_at",
13
+ "type": "timestamptz(6)",
12
14
  "unsigned": false,
13
15
  "autoincrement": false,
14
16
  "primary": false,
15
17
  "nullable": false,
16
- "default": "gen_random_uuid()",
18
+ "unique": false,
19
+ "length": 6,
20
+ "precision": null,
21
+ "scale": null,
22
+ "default": null,
23
+ "comment": null,
24
+ "collation": null,
25
+ "enumItems": [],
26
+ "mappedType": "datetime"
27
+ },
28
+ "created_by": {
29
+ "name": "created_by",
30
+ "type": "uuid",
31
+ "unsigned": false,
32
+ "autoincrement": false,
33
+ "primary": false,
34
+ "nullable": true,
35
+ "unique": false,
36
+ "length": null,
37
+ "precision": null,
38
+ "scale": null,
39
+ "default": null,
40
+ "comment": null,
41
+ "collation": null,
42
+ "enumItems": [],
17
43
  "mappedType": "uuid"
18
44
  },
19
- "name": {
20
- "name": "name",
21
- "type": "text",
45
+ "deleted_at": {
46
+ "name": "deleted_at",
47
+ "type": "timestamptz(6)",
22
48
  "unsigned": false,
23
49
  "autoincrement": false,
24
50
  "primary": false,
25
- "nullable": false,
26
- "mappedType": "text"
51
+ "nullable": true,
52
+ "unique": false,
53
+ "length": 6,
54
+ "precision": null,
55
+ "scale": null,
56
+ "default": null,
57
+ "comment": null,
58
+ "collation": null,
59
+ "enumItems": [],
60
+ "mappedType": "datetime"
27
61
  },
28
62
  "description": {
29
63
  "name": "description",
@@ -32,24 +66,48 @@
32
66
  "autoincrement": false,
33
67
  "primary": false,
34
68
  "nullable": true,
69
+ "unique": false,
70
+ "length": null,
71
+ "precision": null,
72
+ "scale": null,
73
+ "default": null,
74
+ "comment": null,
75
+ "collation": null,
76
+ "enumItems": [],
35
77
  "mappedType": "text"
36
78
  },
37
- "tenant_id": {
38
- "name": "tenant_id",
39
- "type": "uuid",
79
+ "expires_at": {
80
+ "name": "expires_at",
81
+ "type": "timestamptz(6)",
40
82
  "unsigned": false,
41
83
  "autoincrement": false,
42
84
  "primary": false,
43
85
  "nullable": true,
44
- "mappedType": "uuid"
86
+ "unique": false,
87
+ "length": 6,
88
+ "precision": null,
89
+ "scale": null,
90
+ "default": null,
91
+ "comment": null,
92
+ "collation": null,
93
+ "enumItems": [],
94
+ "mappedType": "datetime"
45
95
  },
46
- "organization_id": {
47
- "name": "organization_id",
96
+ "id": {
97
+ "name": "id",
48
98
  "type": "uuid",
49
99
  "unsigned": false,
50
100
  "autoincrement": false,
51
- "primary": false,
52
- "nullable": true,
101
+ "primary": true,
102
+ "nullable": false,
103
+ "unique": false,
104
+ "length": null,
105
+ "precision": null,
106
+ "scale": null,
107
+ "default": "gen_random_uuid()",
108
+ "comment": null,
109
+ "collation": null,
110
+ "enumItems": [],
53
111
  "mappedType": "uuid"
54
112
  },
55
113
  "key_hash": {
@@ -59,6 +117,14 @@
59
117
  "autoincrement": false,
60
118
  "primary": false,
61
119
  "nullable": false,
120
+ "unique": false,
121
+ "length": null,
122
+ "precision": null,
123
+ "scale": null,
124
+ "default": null,
125
+ "comment": null,
126
+ "collation": null,
127
+ "enumItems": [],
62
128
  "mappedType": "text"
63
129
  },
64
130
  "key_prefix": {
@@ -68,44 +134,101 @@
68
134
  "autoincrement": false,
69
135
  "primary": false,
70
136
  "nullable": false,
137
+ "unique": true,
138
+ "length": null,
139
+ "precision": null,
140
+ "scale": null,
141
+ "default": null,
142
+ "comment": null,
143
+ "collation": null,
144
+ "enumItems": [],
71
145
  "mappedType": "text"
72
146
  },
73
- "roles_json": {
74
- "name": "roles_json",
75
- "type": "jsonb",
147
+ "last_used_at": {
148
+ "name": "last_used_at",
149
+ "type": "timestamptz(6)",
76
150
  "unsigned": false,
77
151
  "autoincrement": false,
78
152
  "primary": false,
79
153
  "nullable": true,
80
- "mappedType": "json"
154
+ "unique": false,
155
+ "length": 6,
156
+ "precision": null,
157
+ "scale": null,
158
+ "default": null,
159
+ "comment": null,
160
+ "collation": null,
161
+ "enumItems": [],
162
+ "mappedType": "datetime"
81
163
  },
82
- "created_by": {
83
- "name": "created_by",
84
- "type": "uuid",
164
+ "name": {
165
+ "name": "name",
166
+ "type": "text",
85
167
  "unsigned": false,
86
168
  "autoincrement": false,
87
169
  "primary": false,
88
- "nullable": true,
89
- "mappedType": "uuid"
170
+ "nullable": false,
171
+ "unique": false,
172
+ "length": null,
173
+ "precision": null,
174
+ "scale": null,
175
+ "default": null,
176
+ "comment": null,
177
+ "collation": null,
178
+ "enumItems": [],
179
+ "mappedType": "text"
90
180
  },
91
- "session_token": {
92
- "name": "session_token",
181
+ "opencode_session_id": {
182
+ "name": "opencode_session_id",
93
183
  "type": "text",
94
184
  "unsigned": false,
95
185
  "autoincrement": false,
96
186
  "primary": false,
97
187
  "nullable": true,
188
+ "unique": false,
189
+ "length": null,
190
+ "precision": null,
191
+ "scale": null,
192
+ "default": null,
193
+ "comment": null,
194
+ "collation": null,
195
+ "enumItems": [],
98
196
  "mappedType": "text"
99
197
  },
100
- "session_user_id": {
101
- "name": "session_user_id",
198
+ "organization_id": {
199
+ "name": "organization_id",
102
200
  "type": "uuid",
103
201
  "unsigned": false,
104
202
  "autoincrement": false,
105
203
  "primary": false,
106
204
  "nullable": true,
205
+ "unique": false,
206
+ "length": null,
207
+ "precision": null,
208
+ "scale": null,
209
+ "default": null,
210
+ "comment": null,
211
+ "collation": null,
212
+ "enumItems": [],
107
213
  "mappedType": "uuid"
108
214
  },
215
+ "roles_json": {
216
+ "name": "roles_json",
217
+ "type": "jsonb",
218
+ "unsigned": false,
219
+ "autoincrement": false,
220
+ "primary": false,
221
+ "nullable": true,
222
+ "unique": false,
223
+ "length": null,
224
+ "precision": null,
225
+ "scale": null,
226
+ "default": null,
227
+ "comment": null,
228
+ "collation": null,
229
+ "enumItems": [],
230
+ "mappedType": "json"
231
+ },
109
232
  "session_secret_encrypted": {
110
233
  "name": "session_secret_encrypted",
111
234
  "type": "text",
@@ -113,88 +236,129 @@
113
236
  "autoincrement": false,
114
237
  "primary": false,
115
238
  "nullable": true,
239
+ "unique": false,
240
+ "length": null,
241
+ "precision": null,
242
+ "scale": null,
243
+ "default": null,
244
+ "comment": null,
245
+ "collation": null,
246
+ "enumItems": [],
116
247
  "mappedType": "text"
117
248
  },
118
- "opencode_session_id": {
119
- "name": "opencode_session_id",
249
+ "session_token": {
250
+ "name": "session_token",
120
251
  "type": "text",
121
252
  "unsigned": false,
122
253
  "autoincrement": false,
123
254
  "primary": false,
124
255
  "nullable": true,
256
+ "unique": false,
257
+ "length": null,
258
+ "precision": null,
259
+ "scale": null,
260
+ "default": null,
261
+ "comment": null,
262
+ "collation": null,
263
+ "enumItems": [],
125
264
  "mappedType": "text"
126
265
  },
127
- "last_used_at": {
128
- "name": "last_used_at",
129
- "type": "timestamptz",
266
+ "session_token_hash": {
267
+ "name": "session_token_hash",
268
+ "type": "text",
130
269
  "unsigned": false,
131
270
  "autoincrement": false,
132
271
  "primary": false,
133
272
  "nullable": true,
134
- "length": 6,
135
- "mappedType": "datetime"
273
+ "unique": false,
274
+ "length": null,
275
+ "precision": null,
276
+ "scale": null,
277
+ "default": null,
278
+ "comment": null,
279
+ "collation": null,
280
+ "enumItems": [],
281
+ "mappedType": "text"
136
282
  },
137
- "expires_at": {
138
- "name": "expires_at",
139
- "type": "timestamptz",
283
+ "session_user_id": {
284
+ "name": "session_user_id",
285
+ "type": "uuid",
140
286
  "unsigned": false,
141
287
  "autoincrement": false,
142
288
  "primary": false,
143
289
  "nullable": true,
144
- "length": 6,
145
- "mappedType": "datetime"
290
+ "unique": false,
291
+ "length": null,
292
+ "precision": null,
293
+ "scale": null,
294
+ "default": null,
295
+ "comment": null,
296
+ "collation": null,
297
+ "enumItems": [],
298
+ "mappedType": "uuid"
146
299
  },
147
- "created_at": {
148
- "name": "created_at",
149
- "type": "timestamptz",
300
+ "tenant_id": {
301
+ "name": "tenant_id",
302
+ "type": "uuid",
150
303
  "unsigned": false,
151
304
  "autoincrement": false,
152
305
  "primary": false,
153
- "nullable": false,
154
- "length": 6,
155
- "mappedType": "datetime"
306
+ "nullable": true,
307
+ "unique": false,
308
+ "length": null,
309
+ "precision": null,
310
+ "scale": null,
311
+ "default": null,
312
+ "comment": null,
313
+ "collation": null,
314
+ "enumItems": [],
315
+ "mappedType": "uuid"
156
316
  },
157
317
  "updated_at": {
158
318
  "name": "updated_at",
159
- "type": "timestamptz",
160
- "unsigned": false,
161
- "autoincrement": false,
162
- "primary": false,
163
- "nullable": true,
164
- "length": 6,
165
- "mappedType": "datetime"
166
- },
167
- "deleted_at": {
168
- "name": "deleted_at",
169
- "type": "timestamptz",
319
+ "type": "timestamptz(6)",
170
320
  "unsigned": false,
171
321
  "autoincrement": false,
172
322
  "primary": false,
173
323
  "nullable": true,
324
+ "unique": false,
174
325
  "length": 6,
326
+ "precision": null,
327
+ "scale": null,
328
+ "default": null,
329
+ "comment": null,
330
+ "collation": null,
331
+ "enumItems": [],
175
332
  "mappedType": "datetime"
176
333
  }
177
334
  },
178
- "name": "api_keys",
179
- "schema": "public",
180
335
  "indexes": [
181
336
  {
182
- "keyName": "api_keys_key_prefix_unique",
183
337
  "columnNames": [
184
338
  "key_prefix"
185
339
  ],
186
340
  "composite": false,
187
341
  "constraint": true,
342
+ "keyName": "api_keys_key_prefix_unique",
188
343
  "primary": false,
189
344
  "unique": true
190
345
  },
191
346
  {
192
- "keyName": "api_keys_pkey",
347
+ "columnNames": [],
348
+ "composite": false,
349
+ "constraint": false,
350
+ "keyName": "api_keys_opencode_session_id_uq",
351
+ "primary": false,
352
+ "unique": false,
353
+ "expression": "create unique index \"api_keys_opencode_session_id_uq\" on \"api_keys\" (\"opencode_session_id\") where \"opencode_session_id\" is not null and \"deleted_at\" is null"
354
+ },
355
+ {
193
356
  "columnNames": [
194
357
  "id"
195
358
  ],
196
359
  "composite": false,
197
360
  "constraint": true,
361
+ "keyName": "api_keys_pkey",
198
362
  "primary": true,
199
363
  "unique": true
200
364
  },
@@ -202,16 +366,18 @@
202
366
  "columnNames": [],
203
367
  "composite": false,
204
368
  "constraint": false,
205
- "keyName": "api_keys_opencode_session_id_uq",
369
+ "keyName": "api_keys_session_token_hash_uq",
206
370
  "primary": false,
207
371
  "unique": false,
208
- "expression": "create unique index \"api_keys_opencode_session_id_uq\" on \"api_keys\" (\"opencode_session_id\") where \"opencode_session_id\" is not null and \"deleted_at\" is null"
372
+ "expression": "create unique index \"api_keys_session_token_hash_uq\" on \"api_keys\" (\"session_token_hash\") where \"session_token_hash\" is not null and \"deleted_at\" is null"
209
373
  }
210
374
  ],
211
375
  "checks": [],
376
+ "triggers": [],
212
377
  "foreignKeys": {},
213
- "nativeEnums": {}
378
+ "comment": null
214
379
  }
215
380
  ],
381
+ "views": [],
216
382
  "nativeEnums": {}
217
- }
383
+ }
@@ -0,0 +1,47 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ export class Migration20261012202110_api_keys extends Migration {
4
+
5
+ override name = 'Migration20261012202110';
6
+
7
+ override async up(): Promise<void> {
8
+ this.addSql(`alter table "api_keys" add "session_token_hash" text null;`);
9
+
10
+ // Backfill before switching any lookup to the hash column (#2254). AI-chat session keys
11
+ // carry a 2-hour TTL and mint once per conversation (ai_assistant chat route, `!sessionId`
12
+ // branch) — a session already in flight when this deploys must keep resolving, or the
13
+ // conversation silently loses its MCP tool-call auth with no automatic re-mint. `sha256()`
14
+ // is a PostgreSQL 13+ built-in (no pgcrypto dependency) and matches
15
+ // `hashSessionToken`'s `createHash('sha256').update(token).digest('hex')` byte for byte.
16
+ this.addSql(`
17
+ update "api_keys"
18
+ set "session_token_hash" = encode(sha256("session_token"::bytea), 'hex')
19
+ where "session_token" is not null and "session_token_hash" is null;
20
+ `);
21
+
22
+ // Legacy rows embedded the raw token in `name` (`__session_<token>__`, for operator
23
+ // visibility in an admin listing) — the same credential leaking a second way. Replace it
24
+ // with a hash-derived, non-secret label before the token itself is cleared below.
25
+ this.addSql(`
26
+ update "api_keys"
27
+ set "name" = '__session_' || substr("session_token_hash", 1, 12) || '__'
28
+ where "session_token" is not null and "name" = '__session_' || "session_token" || '__';
29
+ `);
30
+
31
+ // Stop storing the plaintext bearer credential. The column is kept, not dropped, per the
32
+ // "renamed/removed DB column" rule in `.ai/review-checklist.md` (keep old column, add new
33
+ // one, backfill, deprecate old) — see `ApiKey.sessionToken`'s `@deprecated` doc comment for
34
+ // the removal criteria for a later migration.
35
+ this.addSql(`update "api_keys" set "session_token" = null where "session_token" is not null;`);
36
+
37
+ this.addSql(`create unique index "api_keys_session_token_hash_uq" on "api_keys" ("session_token_hash") where "session_token_hash" is not null and "deleted_at" is null;`);
38
+ }
39
+
40
+ override down(): void | Promise<void> {
41
+ // Schema-only reversal. The plaintext `session_token` values cleared above cannot be
42
+ // restored — that data loss is the point of the migration, not an oversight.
43
+ this.addSql(`drop index "api_keys_session_token_hash_uq";`);
44
+ this.addSql(`alter table "api_keys" drop column "session_token_hash";`);
45
+ }
46
+
47
+ }
@@ -1,4 +1,4 @@
1
- import { randomBytes } from 'node:crypto'
1
+ import { randomBytes, createHash } from 'node:crypto'
2
2
  import type { EntityManager } from '@mikro-orm/postgresql'
3
3
  import { hash, compare } from 'bcryptjs'
4
4
  import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
@@ -227,6 +227,17 @@ export function generateSessionToken(): string {
227
227
  return `sess_${randomBytes(16).toString('hex')}`
228
228
  }
229
229
 
230
+ /**
231
+ * Hash a session token for storage/lookup (#2254). The session token is a bearer credential —
232
+ * it unlocks the decrypted API key secret and the user's ACL on the MCP server — so, mirroring
233
+ * `customer_accounts`' `hashToken`, only its hash is ever persisted. 128 bits of entropy from
234
+ * `generateSessionToken` is sufficient for a plain SHA-256 (no bcrypt cost needed, and a slow
235
+ * hash would just add latency to every MCP tool call that resolves a session).
236
+ */
237
+ export function hashSessionToken(sessionToken: string): string {
238
+ return createHash('sha256').update(sessionToken).digest('hex')
239
+ }
240
+
230
241
  /**
231
242
  * Create an ephemeral API key scoped to a chat session.
232
243
  * The key inherits the user's roles and expires after ttlMinutes (default 30).
@@ -244,12 +255,16 @@ export async function createSessionApiKey(
244
255
  // Encrypt the secret for later retrieval (used by MCP server for API calls)
245
256
  const encryptedSecret = await encryptSessionSecret(secret, input.tenantId ?? null)
246
257
 
258
+ // Hash before storage (#2254) — the row (and its `name` column, previously embedding the raw
259
+ // token for operator visibility) must never carry the live bearer credential in the clear.
260
+ const sessionTokenHash = hashSessionToken(input.sessionToken)
261
+
247
262
  const roleIds = Array.from(new Set(input.userRoles.filter(Boolean))).sort((left, right) => left.localeCompare(right))
248
263
  let record!: ApiKey
249
264
  await withAtomicFlush(em, [async () => {
250
265
  await lockRoleWriterAuthorizationState(em, roleIds)
251
266
  record = em.create(ApiKey, {
252
- name: `__session_${input.sessionToken}__`,
267
+ name: `__session_${sessionTokenHash.slice(0, 12)}__`,
253
268
  description: 'Ephemeral session API key for AI chat',
254
269
  tenantId: input.tenantId ?? null,
255
270
  organizationId: input.organizationId ?? null,
@@ -257,7 +272,7 @@ export async function createSessionApiKey(
257
272
  keyPrefix: prefix,
258
273
  rolesJson: roleIds,
259
274
  createdBy: input.userId,
260
- sessionToken: input.sessionToken,
275
+ sessionTokenHash,
261
276
  sessionUserId: input.userId,
262
277
  sessionSecretEncrypted: encryptedSecret,
263
278
  expiresAt,
@@ -283,10 +298,26 @@ export async function findApiKeyBySessionToken(
283
298
  ): Promise<ApiKey | null> {
284
299
  if (!sessionToken) return null
285
300
 
286
- const record = await em.findOne(ApiKey, {
287
- sessionToken,
288
- deletedAt: null,
289
- })
301
+ // Looked up by hash, never by the raw token (#2254).
302
+ const sessionTokenHash = hashSessionToken(sessionToken)
303
+ let record = await em.findOne(ApiKey, { sessionTokenHash, deletedAt: null })
304
+
305
+ if (!record) {
306
+ // Version-skew fallback (#7304): during a rolling deploy/rollback window the app and the
307
+ // separate MCP process can briefly run different code versions. A row minted by
308
+ // pre-#2254 code in that window carries only the plaintext token (sessionTokenHash is
309
+ // still null) — fall back to it so the session still resolves instead of answering a
310
+ // false SESSION_EXPIRED, then self-heal the row onto the hash immediately so this is the
311
+ // only lookup that ever sees the plaintext, and the window closes as soon as each
312
+ // outstanding legacy row is touched once.
313
+ const legacyRecord = await em.findOne(ApiKey, { sessionToken, deletedAt: null })
314
+ if (legacyRecord) {
315
+ legacyRecord.sessionTokenHash = sessionTokenHash
316
+ legacyRecord.sessionToken = null
317
+ await em.persist(legacyRecord).flush()
318
+ record = legacyRecord
319
+ }
320
+ }
290
321
 
291
322
  if (!record) return null
292
323
  if (record.expiresAt && record.expiresAt.getTime() < Date.now()) return null
@@ -392,14 +423,29 @@ export async function deleteSessionApiKey(
392
423
  em: EntityManager,
393
424
  sessionToken: string
394
425
  ): Promise<void> {
395
- const candidate = await em.findOne(ApiKey, { sessionToken, deletedAt: null })
426
+ // Looked up and re-checked by hash, never by the raw token (#2254). Deliberately does not
427
+ // call findApiKeyBySessionToken: that helper also drops already-expired rows, but deleting
428
+ // an expired-but-not-yet-cleaned-up row must still work here.
429
+ const sessionTokenHash = hashSessionToken(sessionToken)
430
+ let candidate = await em.findOne(ApiKey, { sessionTokenHash, deletedAt: null })
431
+ if (!candidate) {
432
+ // Version-skew fallback (#7304): see findApiKeyBySessionToken for the full rationale — a
433
+ // row minted by pre-#2254 code during a deploy window is plaintext-only until touched.
434
+ const legacyCandidate = await em.findOne(ApiKey, { sessionToken, deletedAt: null })
435
+ if (legacyCandidate) {
436
+ legacyCandidate.sessionTokenHash = sessionTokenHash
437
+ legacyCandidate.sessionToken = null
438
+ await em.persist(legacyCandidate).flush()
439
+ candidate = legacyCandidate
440
+ }
441
+ }
396
442
  if (!candidate) return
397
443
  let deletedId: string | null = null
398
444
  await withAtomicFlush(em, [async () => {
399
445
  const records = await lockAuthorizationApiKeyRows(em, [String(candidate.id)])
400
446
  const record = records.find((entry) => (
401
447
  String(entry.id) === String(candidate.id)
402
- && entry.sessionToken === sessionToken
448
+ && entry.sessionTokenHash === sessionTokenHash
403
449
  && !entry.deletedAt
404
450
  ))
405
451
  if (!record) return
@@ -155,6 +155,14 @@ export class OcrService {
155
155
  guidance = 'Rate limit exceeded. Please try again later.'
156
156
  break
157
157
  default:
158
+ if (err?.name === 'TimeoutError') {
159
+ guidance = `OCR request timed out: ${rawMessage}`
160
+ break
161
+ }
162
+ if (err?.name === 'AbortError') {
163
+ guidance = `OCR request was aborted: ${rawMessage}`
164
+ break
165
+ }
158
166
  guidance = rawMessage.includes('https://')
159
167
  ? rawMessage
160
168
  : `${rawMessage}. Check OPENAI_API_KEY.`
@@ -86,6 +86,8 @@ export async function POST(req: Request) {
86
86
  const res = buildSafeRedirectResponse(req, '/login')
87
87
  res.cookies.set('auth_token', '', { path: '/', maxAge: 0 })
88
88
  res.cookies.set('session_token', '', { path: '/', maxAge: 0 })
89
+ res.cookies.set('om_selected_org', '', { path: '/', maxAge: 0 })
90
+ res.cookies.set('om_selected_tenant', '', { path: '/', maxAge: 0 })
89
91
  return res
90
92
  }
91
93
 
@@ -99,7 +101,7 @@ export const openApi: OpenApiRouteDoc = {
99
101
  methods: {
100
102
  POST: {
101
103
  summary: 'Invalidate session and redirect',
102
- description: 'Revokes the session identified by the `auth_token` sid claim and the `session_token` cookie, clears both cookies, and redirects the browser to the login page. A valid access token is not required, so a logout submitted after the access token expired still revokes the remember-me refresh session. A request that carries neither cookie is only redirected.',
104
+ description: 'Revokes the session identified by the `auth_token` sid claim and the `session_token` cookie, clears both cookies together with the organization-scope cookies (`om_selected_org`, `om_selected_tenant`), and redirects the browser to the login page. A valid access token is not required, so a logout submitted after the access token expired still revokes the remember-me refresh session. A request that carries neither session cookie is only redirected.',
103
105
  responses: [
104
106
  { status: 307, description: 'Redirect to login after successful logout', mediaType: 'text/html' },
105
107
  ],
@@ -228,6 +228,8 @@
228
228
  "auth.acl.features.forms.submissions.export": "Einreichungsdaten exportieren (Datenübertragbarkeit, DSGVO Art. 15/20)",
229
229
  "auth.acl.features.forms.submissions.manage": "Einreichungen verwalten (erneut öffnen, Beteiligte zuweisen, exportieren)",
230
230
  "auth.acl.features.forms.view": "Formulare und Einreichungen ansehen",
231
+ "auth.acl.features.gateway_autopay.configure": "Configure Autopay gateway settings",
232
+ "auth.acl.features.gateway_autopay.view": "View Autopay gateway configuration",
231
233
  "auth.acl.features.gateway_stripe.configure": "Configure Stripe gateway settings",
232
234
  "auth.acl.features.gateway_stripe.view": "View Stripe gateway configuration",
233
235
  "auth.acl.features.gateway_tpay.configure": "Tpay-Gateway-Einstellungen konfigurieren",
@@ -459,6 +461,7 @@
459
461
  "auth.acl.modules.example": "Example Module",
460
462
  "auth.acl.modules.example_customers_sync": "Example Customers Sync",
461
463
  "auth.acl.modules.feature_toggles": "Feature Toggles",
464
+ "auth.acl.modules.gateway_autopay": "Autopay Payment Gateway",
462
465
  "auth.acl.modules.gateway_stripe": "Stripe Payment Gateway",
463
466
  "auth.acl.modules.gateway_tpay": "Tpay-Zahlungsgateway",
464
467
  "auth.acl.modules.inbox_ops": "InboxOps — Email-to-ERP Agent",