@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
@@ -41,6 +41,9 @@ __decorateClass([
41
41
  __decorateClass([
42
42
  Property({ name: "created_by", type: "uuid", nullable: true })
43
43
  ], ApiKey.prototype, "createdBy", 2);
44
+ __decorateClass([
45
+ Property({ name: "session_token_hash", type: "text", nullable: true })
46
+ ], ApiKey.prototype, "sessionTokenHash", 2);
44
47
  __decorateClass([
45
48
  Property({ name: "session_token", type: "text", nullable: true })
46
49
  ], ApiKey.prototype, "sessionToken", 2);
@@ -74,6 +77,10 @@ ApiKey = __decorateClass([
74
77
  Index({
75
78
  name: "api_keys_opencode_session_id_uq",
76
79
  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'
80
+ }),
81
+ Index({
82
+ name: "api_keys_session_token_hash_uq",
83
+ 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'
77
84
  })
78
85
  ], ApiKey);
79
86
  export {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/api_keys/data/entities.ts"],
4
- "sourcesContent": ["import { Entity, Index, PrimaryKey, Property, Unique } from '@mikro-orm/decorators/legacy'\n\n@Entity({ tableName: 'api_keys' })\n// The unique keyPrefix bounds the bcrypt candidate loop in findApiKeyBySecret to at\n// most one live row. Do not drop this constraint or widen the prefix space without\n// re-evaluating that loop's per-request cost (see #3812).\n@Unique({ properties: ['keyPrefix'] })\n@Index({\n name: 'api_keys_opencode_session_id_uq',\n expression:\n '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',\n})\nexport class ApiKey {\n @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })\n id!: string\n\n @Property({ type: 'text' })\n name!: string\n\n @Property({ name: 'description', type: 'text', nullable: true })\n description?: string | null\n\n @Property({ name: 'tenant_id', type: 'uuid', nullable: true })\n tenantId?: string | null\n\n @Property({ name: 'organization_id', type: 'uuid', nullable: true })\n organizationId?: string | null\n\n @Property({ name: 'key_hash', type: 'text' })\n keyHash!: string\n\n @Property({ name: 'key_prefix', type: 'text' })\n keyPrefix!: string\n\n @Property({ name: 'roles_json', type: 'json', nullable: true })\n rolesJson?: string[] | null\n\n @Property({ name: 'created_by', type: 'uuid', nullable: true })\n createdBy?: string | null\n\n /** Session token for ephemeral session-scoped keys (used by AI chat) */\n @Property({ name: 'session_token', type: 'text', nullable: true })\n sessionToken?: string | null\n\n /** User ID who owns this session (for ephemeral keys) */\n @Property({ name: 'session_user_id', type: 'uuid', nullable: true })\n sessionUserId?: string | null\n\n /** Encrypted API key secret for session keys (recoverable for API calls) */\n @Property({ name: 'session_secret_encrypted', type: 'text', nullable: true })\n sessionSecretEncrypted?: string | null\n\n /**\n * OpenCode session id bound to this api_key row. Set the first time the\n * chat dispatcher receives a `done` event after minting a session token,\n * then asserted on every subsequent resume to prevent cross-user session\n * continuation (see security fix 2026-05-23).\n */\n @Property({ name: 'opencode_session_id', type: 'text', nullable: true })\n opencodeSessionId?: string | null\n\n @Property({ name: 'last_used_at', type: Date, nullable: true })\n lastUsedAt?: Date | null\n\n @Property({ name: 'expires_at', type: Date, nullable: true })\n expiresAt?: Date | null\n\n @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })\n createdAt: Date = new Date()\n\n @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date(), nullable: true })\n updatedAt?: Date\n\n @Property({ name: 'deleted_at', type: Date, nullable: true })\n deletedAt?: Date | null\n}\n"],
5
- "mappings": ";;;;;;;;;;AAAA,SAAS,QAAQ,OAAO,YAAY,UAAU,cAAc;AAYrD,IAAM,SAAN,MAAa;AAAA,EAAb;AAwDL,qBAAkB,oBAAI,KAAK;AAAA;AAO7B;AA7DE;AAAA,EADC,WAAW,EAAE,MAAM,QAAQ,YAAY,oBAAoB,CAAC;AAAA,GADlD,OAEX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,OAAO,CAAC;AAAA,GAJf,OAKX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,eAAe,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GAPpD,OAQX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,aAAa,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GAVlD,OAWX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,mBAAmB,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GAbxD,OAcX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,YAAY,MAAM,OAAO,CAAC;AAAA,GAhBjC,OAiBX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,cAAc,MAAM,OAAO,CAAC;AAAA,GAnBnC,OAoBX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,cAAc,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GAtBnD,OAuBX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,cAAc,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GAzBnD,OA0BX;AAIA;AAAA,EADC,SAAS,EAAE,MAAM,iBAAiB,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GA7BtD,OA8BX;AAIA;AAAA,EADC,SAAS,EAAE,MAAM,mBAAmB,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GAjCxD,OAkCX;AAIA;AAAA,EADC,SAAS,EAAE,MAAM,4BAA4B,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GArCjE,OAsCX;AASA;AAAA,EADC,SAAS,EAAE,MAAM,uBAAuB,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GA9C5D,OA+CX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,gBAAgB,MAAM,MAAM,UAAU,KAAK,CAAC;AAAA,GAjDnD,OAkDX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,cAAc,MAAM,MAAM,UAAU,KAAK,CAAC;AAAA,GApDjD,OAqDX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,cAAc,MAAM,MAAM,UAAU,MAAM,oBAAI,KAAK,EAAE,CAAC;AAAA,GAvD7D,OAwDX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,cAAc,MAAM,MAAM,UAAU,MAAM,oBAAI,KAAK,GAAG,UAAU,KAAK,CAAC;AAAA,GA1D7E,OA2DX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,cAAc,MAAM,MAAM,UAAU,KAAK,CAAC;AAAA,GA7DjD,OA8DX;AA9DW,SAAN;AAAA,EAVN,OAAO,EAAE,WAAW,WAAW,CAAC;AAAA,EAIhC,OAAO,EAAE,YAAY,CAAC,WAAW,EAAE,CAAC;AAAA,EACpC,MAAM;AAAA,IACL,MAAM;AAAA,IACN,YACE;AAAA,EACJ,CAAC;AAAA,GACY;",
4
+ "sourcesContent": ["import { Entity, Index, PrimaryKey, Property, Unique } from '@mikro-orm/decorators/legacy'\n\n@Entity({ tableName: 'api_keys' })\n// The unique keyPrefix bounds the bcrypt candidate loop in findApiKeyBySecret to at\n// most one live row. Do not drop this constraint or widen the prefix space without\n// re-evaluating that loop's per-request cost (see #3812).\n@Unique({ properties: ['keyPrefix'] })\n@Index({\n name: 'api_keys_opencode_session_id_uq',\n expression:\n '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',\n})\n@Index({\n name: 'api_keys_session_token_hash_uq',\n expression:\n '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',\n})\nexport class ApiKey {\n @PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })\n id!: string\n\n @Property({ type: 'text' })\n name!: string\n\n @Property({ name: 'description', type: 'text', nullable: true })\n description?: string | null\n\n @Property({ name: 'tenant_id', type: 'uuid', nullable: true })\n tenantId?: string | null\n\n @Property({ name: 'organization_id', type: 'uuid', nullable: true })\n organizationId?: string | null\n\n @Property({ name: 'key_hash', type: 'text' })\n keyHash!: string\n\n @Property({ name: 'key_prefix', type: 'text' })\n keyPrefix!: string\n\n @Property({ name: 'roles_json', type: 'json', nullable: true })\n rolesJson?: string[] | null\n\n @Property({ name: 'created_by', type: 'uuid', nullable: true })\n createdBy?: string | null\n\n /**\n * SHA-256 hash of the session token for ephemeral session-scoped keys (used by AI chat).\n * The raw token is a bearer credential \u2014 the same one that unlocks the decrypted API key\n * secret and the user's ACL on the MCP server \u2014 so, like `customer_accounts` session\n * tokens, only its hash is persisted. It is returned to the caller once, at issuance, and\n * never stored or logged in the clear (see #2254).\n */\n @Property({ name: 'session_token_hash', type: 'text', nullable: true })\n sessionTokenHash?: string | null\n\n /**\n * @deprecated Superseded by `sessionTokenHash` (#2254) \u2014 nothing reads or writes this\n * anymore and `Migration20261012202110_api_keys` nulls out every existing value as part of\n * scrubbing the plaintext token. Kept, not dropped, per the \"renamed/removed DB column\" rule in\n * `.ai/review-checklist.md` (keep old column, add new one, backfill, deprecate old) \u2014 chat\n * sessions in flight across the deploy carry a 2-hour TTL and only mint once per\n * conversation (`ai_assistant`'s chat route, `!sessionId` branch), so the column must survive\n * long enough for every session minted before this ships to expire naturally. Safe to drop in\n * a later migration once that window has passed.\n */\n @Property({ name: 'session_token', type: 'text', nullable: true })\n sessionToken?: string | null\n\n /** User ID who owns this session (for ephemeral keys) */\n @Property({ name: 'session_user_id', type: 'uuid', nullable: true })\n sessionUserId?: string | null\n\n /** Encrypted API key secret for session keys (recoverable for API calls) */\n @Property({ name: 'session_secret_encrypted', type: 'text', nullable: true })\n sessionSecretEncrypted?: string | null\n\n /**\n * OpenCode session id bound to this api_key row. Set the first time the\n * chat dispatcher receives a `done` event after minting a session token,\n * then asserted on every subsequent resume to prevent cross-user session\n * continuation (see security fix 2026-05-23).\n */\n @Property({ name: 'opencode_session_id', type: 'text', nullable: true })\n opencodeSessionId?: string | null\n\n @Property({ name: 'last_used_at', type: Date, nullable: true })\n lastUsedAt?: Date | null\n\n @Property({ name: 'expires_at', type: Date, nullable: true })\n expiresAt?: Date | null\n\n @Property({ name: 'created_at', type: Date, onCreate: () => new Date() })\n createdAt: Date = new Date()\n\n @Property({ name: 'updated_at', type: Date, onUpdate: () => new Date(), nullable: true })\n updatedAt?: Date\n\n @Property({ name: 'deleted_at', type: Date, nullable: true })\n deletedAt?: Date | null\n}\n"],
5
+ "mappings": ";;;;;;;;;;AAAA,SAAS,QAAQ,OAAO,YAAY,UAAU,cAAc;AAiBrD,IAAM,SAAN,MAAa;AAAA,EAAb;AA2EL,qBAAkB,oBAAI,KAAK;AAAA;AAO7B;AAhFE;AAAA,EADC,WAAW,EAAE,MAAM,QAAQ,YAAY,oBAAoB,CAAC;AAAA,GADlD,OAEX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,OAAO,CAAC;AAAA,GAJf,OAKX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,eAAe,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GAPpD,OAQX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,aAAa,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GAVlD,OAWX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,mBAAmB,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GAbxD,OAcX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,YAAY,MAAM,OAAO,CAAC;AAAA,GAhBjC,OAiBX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,cAAc,MAAM,OAAO,CAAC;AAAA,GAnBnC,OAoBX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,cAAc,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GAtBnD,OAuBX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,cAAc,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GAzBnD,OA0BX;AAUA;AAAA,EADC,SAAS,EAAE,MAAM,sBAAsB,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GAnC3D,OAoCX;AAaA;AAAA,EADC,SAAS,EAAE,MAAM,iBAAiB,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GAhDtD,OAiDX;AAIA;AAAA,EADC,SAAS,EAAE,MAAM,mBAAmB,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GApDxD,OAqDX;AAIA;AAAA,EADC,SAAS,EAAE,MAAM,4BAA4B,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GAxDjE,OAyDX;AASA;AAAA,EADC,SAAS,EAAE,MAAM,uBAAuB,MAAM,QAAQ,UAAU,KAAK,CAAC;AAAA,GAjE5D,OAkEX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,gBAAgB,MAAM,MAAM,UAAU,KAAK,CAAC;AAAA,GApEnD,OAqEX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,cAAc,MAAM,MAAM,UAAU,KAAK,CAAC;AAAA,GAvEjD,OAwEX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,cAAc,MAAM,MAAM,UAAU,MAAM,oBAAI,KAAK,EAAE,CAAC;AAAA,GA1E7D,OA2EX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,cAAc,MAAM,MAAM,UAAU,MAAM,oBAAI,KAAK,GAAG,UAAU,KAAK,CAAC;AAAA,GA7E7E,OA8EX;AAGA;AAAA,EADC,SAAS,EAAE,MAAM,cAAc,MAAM,MAAM,UAAU,KAAK,CAAC;AAAA,GAhFjD,OAiFX;AAjFW,SAAN;AAAA,EAfN,OAAO,EAAE,WAAW,WAAW,CAAC;AAAA,EAIhC,OAAO,EAAE,YAAY,CAAC,WAAW,EAAE,CAAC;AAAA,EACpC,MAAM;AAAA,IACL,MAAM;AAAA,IACN,YACE;AAAA,EACJ,CAAC;AAAA,EACA,MAAM;AAAA,IACL,MAAM;AAAA,IACN,YACE;AAAA,EACJ,CAAC;AAAA,GACY;",
6
6
  "names": []
7
7
  }
@@ -0,0 +1,30 @@
1
+ import { Migration } from "@mikro-orm/migrations";
2
+ class Migration20261012202110_api_keys extends Migration {
3
+ constructor() {
4
+ super(...arguments);
5
+ this.name = "Migration20261012202110";
6
+ }
7
+ async up() {
8
+ this.addSql(`alter table "api_keys" add "session_token_hash" text null;`);
9
+ this.addSql(`
10
+ update "api_keys"
11
+ set "session_token_hash" = encode(sha256("session_token"::bytea), 'hex')
12
+ where "session_token" is not null and "session_token_hash" is null;
13
+ `);
14
+ this.addSql(`
15
+ update "api_keys"
16
+ set "name" = '__session_' || substr("session_token_hash", 1, 12) || '__'
17
+ where "session_token" is not null and "name" = '__session_' || "session_token" || '__';
18
+ `);
19
+ this.addSql(`update "api_keys" set "session_token" = null where "session_token" is not null;`);
20
+ 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;`);
21
+ }
22
+ down() {
23
+ this.addSql(`drop index "api_keys_session_token_hash_uq";`);
24
+ this.addSql(`alter table "api_keys" drop column "session_token_hash";`);
25
+ }
26
+ }
27
+ export {
28
+ Migration20261012202110_api_keys
29
+ };
30
+ //# sourceMappingURL=Migration20261012202110_api_keys.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../src/modules/api_keys/migrations/Migration20261012202110_api_keys.ts"],
4
+ "sourcesContent": ["import { Migration } from '@mikro-orm/migrations';\n\nexport class Migration20261012202110_api_keys extends Migration {\n\n override name = 'Migration20261012202110';\n\n override async up(): Promise<void> {\n this.addSql(`alter table \"api_keys\" add \"session_token_hash\" text null;`);\n\n // Backfill before switching any lookup to the hash column (#2254). AI-chat session keys\n // carry a 2-hour TTL and mint once per conversation (ai_assistant chat route, `!sessionId`\n // branch) \u2014 a session already in flight when this deploys must keep resolving, or the\n // conversation silently loses its MCP tool-call auth with no automatic re-mint. `sha256()`\n // is a PostgreSQL 13+ built-in (no pgcrypto dependency) and matches\n // `hashSessionToken`'s `createHash('sha256').update(token).digest('hex')` byte for byte.\n this.addSql(`\n update \"api_keys\"\n set \"session_token_hash\" = encode(sha256(\"session_token\"::bytea), 'hex')\n where \"session_token\" is not null and \"session_token_hash\" is null;\n `);\n\n // Legacy rows embedded the raw token in `name` (`__session_<token>__`, for operator\n // visibility in an admin listing) \u2014 the same credential leaking a second way. Replace it\n // with a hash-derived, non-secret label before the token itself is cleared below.\n this.addSql(`\n update \"api_keys\"\n set \"name\" = '__session_' || substr(\"session_token_hash\", 1, 12) || '__'\n where \"session_token\" is not null and \"name\" = '__session_' || \"session_token\" || '__';\n `);\n\n // Stop storing the plaintext bearer credential. The column is kept, not dropped, per the\n // \"renamed/removed DB column\" rule in `.ai/review-checklist.md` (keep old column, add new\n // one, backfill, deprecate old) \u2014 see `ApiKey.sessionToken`'s `@deprecated` doc comment for\n // the removal criteria for a later migration.\n this.addSql(`update \"api_keys\" set \"session_token\" = null where \"session_token\" is not null;`);\n\n 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;`);\n }\n\n override down(): void | Promise<void> {\n // Schema-only reversal. The plaintext `session_token` values cleared above cannot be\n // restored \u2014 that data loss is the point of the migration, not an oversight.\n this.addSql(`drop index \"api_keys_session_token_hash_uq\";`);\n this.addSql(`alter table \"api_keys\" drop column \"session_token_hash\";`);\n }\n\n}\n"],
5
+ "mappings": "AAAA,SAAS,iBAAiB;AAEnB,MAAM,yCAAyC,UAAU;AAAA,EAAzD;AAAA;AAEL,SAAS,OAAO;AAAA;AAAA,EAEhB,MAAe,KAAoB;AACjC,SAAK,OAAO,4DAA4D;AAQxE,SAAK,OAAO;AAAA;AAAA;AAAA;AAAA,KAIX;AAKD,SAAK,OAAO;AAAA;AAAA;AAAA;AAAA,KAIX;AAMD,SAAK,OAAO,iFAAiF;AAE7F,SAAK,OAAO,4JAA4J;AAAA,EAC1K;AAAA,EAES,OAA6B;AAGpC,SAAK,OAAO,8CAA8C;AAC1D,SAAK,OAAO,0DAA0D;AAAA,EACxE;AAEF;",
6
+ "names": []
7
+ }
@@ -1,4 +1,4 @@
1
- import { randomBytes } from "node:crypto";
1
+ import { randomBytes, createHash } from "node:crypto";
2
2
  import { hash, compare } from "bcryptjs";
3
3
  import { ApiKey } from "../data/entities.js";
4
4
  import { createKmsService, resolveEncryptionMode } from "@open-mercato/shared/lib/encryption/kms";
@@ -123,18 +123,22 @@ async function findApiKeyBySecret(em, secret) {
123
123
  function generateSessionToken() {
124
124
  return `sess_${randomBytes(16).toString("hex")}`;
125
125
  }
126
+ function hashSessionToken(sessionToken) {
127
+ return createHash("sha256").update(sessionToken).digest("hex");
128
+ }
126
129
  async function createSessionApiKey(em, input) {
127
130
  const { secret, prefix } = generateApiKeySecret();
128
131
  const ttl = input.ttlMinutes ?? 30;
129
132
  const expiresAt = new Date(Date.now() + ttl * 60 * 1e3);
130
133
  const keyHash = await hashApiKey(secret);
131
134
  const encryptedSecret = await encryptSessionSecret(secret, input.tenantId ?? null);
135
+ const sessionTokenHash = hashSessionToken(input.sessionToken);
132
136
  const roleIds = Array.from(new Set(input.userRoles.filter(Boolean))).sort((left, right) => left.localeCompare(right));
133
137
  let record;
134
138
  await withAtomicFlush(em, [async () => {
135
139
  await lockRoleWriterAuthorizationState(em, roleIds);
136
140
  record = em.create(ApiKey, {
137
- name: `__session_${input.sessionToken}__`,
141
+ name: `__session_${sessionTokenHash.slice(0, 12)}__`,
138
142
  description: "Ephemeral session API key for AI chat",
139
143
  tenantId: input.tenantId ?? null,
140
144
  organizationId: input.organizationId ?? null,
@@ -142,7 +146,7 @@ async function createSessionApiKey(em, input) {
142
146
  keyPrefix: prefix,
143
147
  rolesJson: roleIds,
144
148
  createdBy: input.userId,
145
- sessionToken: input.sessionToken,
149
+ sessionTokenHash,
146
150
  sessionUserId: input.userId,
147
151
  sessionSecretEncrypted: encryptedSecret,
148
152
  expiresAt,
@@ -158,10 +162,17 @@ async function createSessionApiKey(em, input) {
158
162
  }
159
163
  async function findApiKeyBySessionToken(em, sessionToken) {
160
164
  if (!sessionToken) return null;
161
- const record = await em.findOne(ApiKey, {
162
- sessionToken,
163
- deletedAt: null
164
- });
165
+ const sessionTokenHash = hashSessionToken(sessionToken);
166
+ let record = await em.findOne(ApiKey, { sessionTokenHash, deletedAt: null });
167
+ if (!record) {
168
+ const legacyRecord = await em.findOne(ApiKey, { sessionToken, deletedAt: null });
169
+ if (legacyRecord) {
170
+ legacyRecord.sessionTokenHash = sessionTokenHash;
171
+ legacyRecord.sessionToken = null;
172
+ await em.persist(legacyRecord).flush();
173
+ record = legacyRecord;
174
+ }
175
+ }
165
176
  if (!record) return null;
166
177
  if (record.expiresAt && record.expiresAt.getTime() < Date.now()) return null;
167
178
  return record;
@@ -204,12 +215,22 @@ async function findSessionApiKeyWithSecret(em, sessionToken) {
204
215
  return { key: record, secret };
205
216
  }
206
217
  async function deleteSessionApiKey(em, sessionToken) {
207
- const candidate = await em.findOne(ApiKey, { sessionToken, deletedAt: null });
218
+ const sessionTokenHash = hashSessionToken(sessionToken);
219
+ let candidate = await em.findOne(ApiKey, { sessionTokenHash, deletedAt: null });
220
+ if (!candidate) {
221
+ const legacyCandidate = await em.findOne(ApiKey, { sessionToken, deletedAt: null });
222
+ if (legacyCandidate) {
223
+ legacyCandidate.sessionTokenHash = sessionTokenHash;
224
+ legacyCandidate.sessionToken = null;
225
+ await em.persist(legacyCandidate).flush();
226
+ candidate = legacyCandidate;
227
+ }
228
+ }
208
229
  if (!candidate) return;
209
230
  let deletedId = null;
210
231
  await withAtomicFlush(em, [async () => {
211
232
  const records = await lockAuthorizationApiKeyRows(em, [String(candidate.id)]);
212
- const record = records.find((entry) => String(entry.id) === String(candidate.id) && entry.sessionToken === sessionToken && !entry.deletedAt);
233
+ const record = records.find((entry) => String(entry.id) === String(candidate.id) && entry.sessionTokenHash === sessionTokenHash && !entry.deletedAt);
213
234
  if (!record) return;
214
235
  record.deletedAt = /* @__PURE__ */ new Date();
215
236
  await em.persist(record).flush();
@@ -251,6 +272,7 @@ export {
251
272
  generateApiKeySecret,
252
273
  generateSessionToken,
253
274
  hashApiKey,
275
+ hashSessionToken,
254
276
  verifyApiKey,
255
277
  withOnetimeApiKey
256
278
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/api_keys/services/apiKeyService.ts"],
4
- "sourcesContent": ["import { randomBytes } from 'node:crypto'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { hash, compare } from 'bcryptjs'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { ApiKey } from '../data/entities'\nimport { createKmsService, resolveEncryptionMode } from '@open-mercato/shared/lib/encryption/kms'\nimport { encryptWithAesGcm, decryptWithAesGcm, looksLikeEncryptedPayload } from '@open-mercato/shared/lib/encryption/aes'\nimport { getSharedApiKeyAuthCache } from '@open-mercato/shared/lib/auth/apiKeyAuthCache'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'\nimport {\n lockAuthorizationApiKeyRows,\n lockRoleWriterAuthorizationState,\n} from '@open-mercato/core/modules/auth/lib/authorizationStateLocks'\n\nconst logger = createLogger('api_keys').child({ component: 'api-key-service' })\n\nconst BCRYPT_COST = 10\n\n// =============================================================================\n// Session Secret Encryption Helpers\n// =============================================================================\n\n/**\n * Seal an ephemeral session API key secret for storage in `session_secret_encrypted`.\n *\n * Returns null when the secret cannot be stored at all, which costs the caller MCP session-token\n * auth (the secret is unrecoverable and `findSessionApiKeyWithSecret` gives up).\n *\n * Under `TENANT_DATA_ENCRYPTION=no` the secret is stored as-is. That is the same bargain the rest\n * of the system already strikes in that mode -- emails, integration credentials and the search\n * index all sit in plaintext -- and it is what keeps the AI chat working when an operator opts\n * out. A DEK that is merely unreachable is a different situation and still yields null: writing a\n * secret in the clear because Vault happens to be down is not a downgrade anyone asked for.\n */\nasync function encryptSessionSecret(\n secret: string,\n tenantId: string | null\n): Promise<string | null> {\n if (!tenantId) return null\n\n const kms = createKmsService()\n const mode = resolveEncryptionMode(kms)\n if (mode === 'disabled') return secret\n if (mode === 'unavailable') {\n logger.warn(\n 'Tenant data encryption is enabled but no DEK is reachable; session secret not stored. '\n + 'MCP session-token auth will fail until the KMS recovers.',\n { tenantId },\n )\n return null\n }\n\n const dek = await kms.getTenantDek(tenantId)\n if (!dek) {\n // Try to create a DEK if one doesn't exist\n const created = await kms.createTenantDek(tenantId)\n if (!created) return null\n const encrypted = encryptWithAesGcm(secret, created.key)\n return encrypted.value\n }\n\n const encrypted = encryptWithAesGcm(secret, dek.key)\n return encrypted.value\n}\n\n/**\n * Recover a session API key secret written by {@link encryptSessionSecret}.\n * Returns null if it cannot be recovered.\n */\nasync function decryptSessionSecret(\n stored: string,\n tenantId: string | null\n): Promise<string | null> {\n if (!tenantId || !stored) return null\n\n const kms = createKmsService()\n const mode = resolveEncryptionMode(kms)\n if (mode === 'disabled') {\n // Written in the clear by the branch above -- unless it predates the toggle being flipped, in\n // which case it is a sealed envelope no key can open and null is the honest answer.\n return looksLikeEncryptedPayload(stored) ? null : stored\n }\n if (mode === 'unavailable') {\n logger.warn('Tenant data encryption is enabled but no DEK is reachable; cannot recover session secret', { tenantId })\n return null\n }\n\n // Mirror of the `disabled` branch: a secret written in the clear while the toggle was off is\n // still recoverable after it is switched back on. Without this `decryptWithAesGcm` reads the\n // plaintext as a malformed envelope and returns null, so the flip would silently break every\n // live session rather than only the ones sealed under the old setting.\n if (!looksLikeEncryptedPayload(stored)) return stored\n\n const dek = await kms.getTenantDek(tenantId)\n if (!dek) return null\n\n return decryptWithAesGcm(stored, dek.key)\n}\n\nexport type CreateApiKeyInput = {\n name: string\n description?: string | null\n tenantId?: string | null\n organizationId?: string | null\n roles?: string[]\n expiresAt?: Date | null\n createdBy?: string | null\n}\n\nexport type ApiKeyWithSecret = {\n record: ApiKey\n secret: string\n}\n\nexport function generateApiKeySecret(): { secret: string; prefix: string } {\n const short = randomBytes(4).toString('hex')\n const body = randomBytes(24).toString('hex')\n const secret = `omk_${short}.${body}`\n const prefix = secret.slice(0, 12)\n return { secret, prefix }\n}\n\nexport async function hashApiKey(secret: string): Promise<string> {\n return hash(secret, BCRYPT_COST)\n}\n\nexport async function verifyApiKey(secret: string, keyHash: string): Promise<boolean> {\n return compare(secret, keyHash)\n}\n\nexport async function createApiKey(\n em: EntityManager,\n input: CreateApiKeyInput,\n opts: {\n rbac?: RbacService\n authorizeRoles?: (roleIds: readonly string[]) => Promise<void> | void\n } = {},\n): Promise<ApiKeyWithSecret> {\n const { secret, prefix } = generateApiKeySecret()\n const keyHash = await hashApiKey(secret)\n const roleIds = Array.from(new Set((input.roles ?? []).filter(Boolean))).sort((left, right) => left.localeCompare(right))\n let record!: ApiKey\n await withAtomicFlush(em, [async () => {\n await lockRoleWriterAuthorizationState(em, roleIds)\n await opts.authorizeRoles?.(roleIds)\n record = em.create(ApiKey, {\n name: input.name,\n description: input.description ?? null,\n tenantId: input.tenantId ?? null,\n organizationId: input.organizationId ?? null,\n keyHash,\n keyPrefix: prefix,\n rolesJson: roleIds,\n createdBy: input.createdBy ?? null,\n expiresAt: input.expiresAt ?? null,\n createdAt: new Date(),\n })\n em.persist(record)\n }], { transaction: true, label: 'api_keys.create' })\n if (opts.rbac) {\n await opts.rbac.invalidateUserCache(`api_key:${record.id}`)\n }\n return { record, secret }\n}\n\nexport async function deleteApiKey(\n em: EntityManager,\n id: string,\n opts: { rbac?: RbacService; authorize?: (record: ApiKey) => Promise<void> | void } = {},\n): Promise<boolean> {\n let deletedId: string | null = null\n await withAtomicFlush(em, [async () => {\n const records = await lockAuthorizationApiKeyRows(em, [id])\n const record = records.find((candidate) => String(candidate.id) === id)\n if (!record || record.deletedAt) return\n await opts.authorize?.(record)\n record.deletedAt = new Date()\n await em.persist(record).flush()\n deletedId = String(record.id)\n }], { transaction: true, label: 'api_keys.delete' })\n if (!deletedId) return false\n getSharedApiKeyAuthCache().invalidateByKeyId(deletedId)\n if (opts.rbac) {\n await opts.rbac.invalidateUserCache(`api_key:${deletedId}`)\n }\n return true\n}\n\nexport async function findApiKeyBySecret(em: EntityManager, secret: string): Promise<ApiKey | null> {\n if (!secret) return null\n // Extract prefix from the secret for fast candidate lookup\n const prefix = secret.slice(0, 12)\n // Find candidates by prefix (fast index lookup). Invariant: the unique keyPrefix\n // constraint plus the deletedAt: null filter keep this to at most one live row, so\n // the bcrypt loop below stays bounded. Do not widen the prefix space or relax either\n // filter without re-evaluating that cost (see #3812).\n const candidates = await em.find(ApiKey, { keyPrefix: prefix, deletedAt: null })\n // Verify each candidate with bcrypt until we find a match\n for (const candidate of candidates) {\n if (candidate.expiresAt && candidate.expiresAt.getTime() < Date.now()) continue\n const isValid = await verifyApiKey(secret, candidate.keyHash)\n if (isValid) return candidate\n }\n return null\n}\n\n// =============================================================================\n// Session-scoped API Keys (for AI Chat ephemeral authorization)\n// =============================================================================\n\nexport type CreateSessionApiKeyInput = {\n sessionToken: string\n userId: string\n userRoles: string[]\n tenantId?: string | null\n organizationId?: string | null\n ttlMinutes?: number\n}\n\n/**\n * Generate a unique session token for ephemeral API keys.\n * Format: sess_{32 hex chars}\n */\nexport function generateSessionToken(): string {\n return `sess_${randomBytes(16).toString('hex')}`\n}\n\n/**\n * Create an ephemeral API key scoped to a chat session.\n * The key inherits the user's roles and expires after ttlMinutes (default 30).\n * The API key secret is encrypted and stored so it can be recovered for API calls.\n */\nexport async function createSessionApiKey(\n em: EntityManager,\n input: CreateSessionApiKeyInput\n): Promise<{ keyId: string; secret: string; sessionToken: string }> {\n const { secret, prefix } = generateApiKeySecret()\n const ttl = input.ttlMinutes ?? 30\n const expiresAt = new Date(Date.now() + ttl * 60 * 1000)\n const keyHash = await hashApiKey(secret)\n\n // Encrypt the secret for later retrieval (used by MCP server for API calls)\n const encryptedSecret = await encryptSessionSecret(secret, input.tenantId ?? null)\n\n const roleIds = Array.from(new Set(input.userRoles.filter(Boolean))).sort((left, right) => left.localeCompare(right))\n let record!: ApiKey\n await withAtomicFlush(em, [async () => {\n await lockRoleWriterAuthorizationState(em, roleIds)\n record = em.create(ApiKey, {\n name: `__session_${input.sessionToken}__`,\n description: 'Ephemeral session API key for AI chat',\n tenantId: input.tenantId ?? null,\n organizationId: input.organizationId ?? null,\n keyHash,\n keyPrefix: prefix,\n rolesJson: roleIds,\n createdBy: input.userId,\n sessionToken: input.sessionToken,\n sessionUserId: input.userId,\n sessionSecretEncrypted: encryptedSecret,\n expiresAt,\n createdAt: new Date(),\n })\n em.persist(record)\n }], { transaction: true, label: 'api_keys.session.create' })\n\n return {\n keyId: record.id,\n secret,\n sessionToken: input.sessionToken,\n }\n}\n\n/**\n * Find an API key by its session token.\n * Returns null if not found, expired, or deleted.\n */\nexport async function findApiKeyBySessionToken(\n em: EntityManager,\n sessionToken: string\n): Promise<ApiKey | null> {\n if (!sessionToken) return null\n\n const record = await em.findOne(ApiKey, {\n sessionToken,\n deletedAt: null,\n })\n\n if (!record) return null\n if (record.expiresAt && record.expiresAt.getTime() < Date.now()) return null\n\n return record\n}\n\n/**\n * Bind an OpenCode session id to the api_key row that owns this chat session.\n *\n * Called by the chat dispatcher the first time we see the `done` event for a\n * freshly minted session token. From that point on,\n * `findApiKeyByOpencodeSessionId(em, opencodeSessionId)` returns the same row,\n * which the ai-assistant runtime uses to assert ownership on every resume.\n *\n * Throws when the session token has been deleted/expired, and when the api_key\n * row is already bound to a DIFFERENT OpenCode session (defensive: this should\n * never happen in practice because each chat mints a new session token, but we\n * fail closed instead of silently overwriting).\n *\n * Idempotent when the row is already bound to the same OpenCode session id.\n */\nexport async function bindOpencodeSessionToApiKey(\n em: EntityManager,\n sessionToken: string,\n opencodeSessionId: string\n): Promise<void> {\n if (!sessionToken) throw new Error('Session token not found or expired')\n if (!opencodeSessionId) throw new Error('OpenCode session id is required')\n\n const row = await findApiKeyBySessionToken(em, sessionToken)\n if (!row) throw new Error('Session token not found or expired')\n\n if (row.opencodeSessionId === opencodeSessionId) return\n if (row.opencodeSessionId && row.opencodeSessionId !== opencodeSessionId) {\n throw new Error('Session token already bound to a different OpenCode session')\n }\n\n row.opencodeSessionId = opencodeSessionId\n await em.persist(row).flush()\n}\n\n/**\n * Find an api_key row by its bound OpenCode session id.\n *\n * Returns null if no active row matches, or if the matched row is expired\n * (same contract as `findApiKeyBySessionToken`). Uses\n * `findOneWithDecryption` so encrypted-at-rest fields on the row are decrypted\n * before the ai-assistant runtime inspects `sessionUserId` / `tenantId` /\n * `organizationId` for the ownership check.\n */\nexport async function findApiKeyByOpencodeSessionId(\n em: EntityManager,\n opencodeSessionId: string\n): Promise<ApiKey | null> {\n if (!opencodeSessionId) return null\n\n const record = await findOneWithDecryption(\n em,\n ApiKey,\n { opencodeSessionId, deletedAt: null } as any,\n )\n\n if (!record) return null\n if (record.expiresAt && record.expiresAt.getTime() < Date.now()) return null\n\n return record\n}\n\n/**\n * Find a session API key with its decrypted secret.\n * Returns null if not found, expired, deleted, or decryption fails.\n * This is used by the MCP server to recover the API key secret for making\n * authenticated API calls on behalf of the user.\n */\nexport async function findSessionApiKeyWithSecret(\n em: EntityManager,\n sessionToken: string\n): Promise<{ key: ApiKey; secret: string } | null> {\n const record = await findApiKeyBySessionToken(em, sessionToken)\n if (!record) return null\n\n // If no encrypted secret stored, cannot recover\n if (!record.sessionSecretEncrypted) {\n logger.warn('Session key has no encrypted secret', { apiKeyId: record.id })\n return null\n }\n\n // Decrypt the secret\n const secret = await decryptSessionSecret(record.sessionSecretEncrypted, record.tenantId ?? null)\n if (!secret) {\n logger.warn('Failed to decrypt session secret', { apiKeyId: record.id })\n return null\n }\n\n return { key: record, secret }\n}\n\n/**\n * Delete an ephemeral API key by its session token.\n */\nexport async function deleteSessionApiKey(\n em: EntityManager,\n sessionToken: string\n): Promise<void> {\n const candidate = await em.findOne(ApiKey, { sessionToken, deletedAt: null })\n if (!candidate) return\n let deletedId: string | null = null\n await withAtomicFlush(em, [async () => {\n const records = await lockAuthorizationApiKeyRows(em, [String(candidate.id)])\n const record = records.find((entry) => (\n String(entry.id) === String(candidate.id)\n && entry.sessionToken === sessionToken\n && !entry.deletedAt\n ))\n if (!record) return\n record.deletedAt = new Date()\n await em.persist(record).flush()\n deletedId = String(record.id)\n }], { transaction: true, label: 'api_keys.session.delete' })\n if (deletedId) getSharedApiKeyAuthCache().invalidateByKeyId(deletedId)\n}\n\n/**\n * Execute a function with a one-time API key\n *\n * Creates a temporary API key, executes the function, and deletes the key.\n * Perfect for workflow activities that need authenticated access without\n * storing long-lived credentials.\n *\n * @param em - Entity manager\n * @param input - API key configuration\n * @param fn - Function to execute with the API key secret\n * @returns Result of the function\n */\nconst ONETIME_KEY_MAX_TTL_MS = 5 * 60 * 1000\n\nexport async function withOnetimeApiKey<T>(\n em: EntityManager,\n input: CreateApiKeyInput,\n fn: (secret: string) => Promise<T>\n): Promise<T> {\n const maxExpiresAt = new Date(Date.now() + ONETIME_KEY_MAX_TTL_MS)\n const safeExpiresAt = input.expiresAt && input.expiresAt < maxExpiresAt\n ? input.expiresAt\n : maxExpiresAt\n\n const { record, secret } = await createApiKey(em, {\n ...input,\n name: input.name || '__onetime__',\n description: input.description || 'One-time API key',\n expiresAt: safeExpiresAt,\n })\n\n try {\n const result = await fn(secret)\n return result\n } finally {\n try {\n await deleteApiKey(em, String(record.id))\n } catch (error) {\n logger.error('Failed to soft-delete one-time API key', { err: error })\n }\n }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { randomBytes, createHash } from 'node:crypto'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { hash, compare } from 'bcryptjs'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { ApiKey } from '../data/entities'\nimport { createKmsService, resolveEncryptionMode } from '@open-mercato/shared/lib/encryption/kms'\nimport { encryptWithAesGcm, decryptWithAesGcm, looksLikeEncryptedPayload } from '@open-mercato/shared/lib/encryption/aes'\nimport { getSharedApiKeyAuthCache } from '@open-mercato/shared/lib/auth/apiKeyAuthCache'\nimport { findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'\nimport {\n lockAuthorizationApiKeyRows,\n lockRoleWriterAuthorizationState,\n} from '@open-mercato/core/modules/auth/lib/authorizationStateLocks'\n\nconst logger = createLogger('api_keys').child({ component: 'api-key-service' })\n\nconst BCRYPT_COST = 10\n\n// =============================================================================\n// Session Secret Encryption Helpers\n// =============================================================================\n\n/**\n * Seal an ephemeral session API key secret for storage in `session_secret_encrypted`.\n *\n * Returns null when the secret cannot be stored at all, which costs the caller MCP session-token\n * auth (the secret is unrecoverable and `findSessionApiKeyWithSecret` gives up).\n *\n * Under `TENANT_DATA_ENCRYPTION=no` the secret is stored as-is. That is the same bargain the rest\n * of the system already strikes in that mode -- emails, integration credentials and the search\n * index all sit in plaintext -- and it is what keeps the AI chat working when an operator opts\n * out. A DEK that is merely unreachable is a different situation and still yields null: writing a\n * secret in the clear because Vault happens to be down is not a downgrade anyone asked for.\n */\nasync function encryptSessionSecret(\n secret: string,\n tenantId: string | null\n): Promise<string | null> {\n if (!tenantId) return null\n\n const kms = createKmsService()\n const mode = resolveEncryptionMode(kms)\n if (mode === 'disabled') return secret\n if (mode === 'unavailable') {\n logger.warn(\n 'Tenant data encryption is enabled but no DEK is reachable; session secret not stored. '\n + 'MCP session-token auth will fail until the KMS recovers.',\n { tenantId },\n )\n return null\n }\n\n const dek = await kms.getTenantDek(tenantId)\n if (!dek) {\n // Try to create a DEK if one doesn't exist\n const created = await kms.createTenantDek(tenantId)\n if (!created) return null\n const encrypted = encryptWithAesGcm(secret, created.key)\n return encrypted.value\n }\n\n const encrypted = encryptWithAesGcm(secret, dek.key)\n return encrypted.value\n}\n\n/**\n * Recover a session API key secret written by {@link encryptSessionSecret}.\n * Returns null if it cannot be recovered.\n */\nasync function decryptSessionSecret(\n stored: string,\n tenantId: string | null\n): Promise<string | null> {\n if (!tenantId || !stored) return null\n\n const kms = createKmsService()\n const mode = resolveEncryptionMode(kms)\n if (mode === 'disabled') {\n // Written in the clear by the branch above -- unless it predates the toggle being flipped, in\n // which case it is a sealed envelope no key can open and null is the honest answer.\n return looksLikeEncryptedPayload(stored) ? null : stored\n }\n if (mode === 'unavailable') {\n logger.warn('Tenant data encryption is enabled but no DEK is reachable; cannot recover session secret', { tenantId })\n return null\n }\n\n // Mirror of the `disabled` branch: a secret written in the clear while the toggle was off is\n // still recoverable after it is switched back on. Without this `decryptWithAesGcm` reads the\n // plaintext as a malformed envelope and returns null, so the flip would silently break every\n // live session rather than only the ones sealed under the old setting.\n if (!looksLikeEncryptedPayload(stored)) return stored\n\n const dek = await kms.getTenantDek(tenantId)\n if (!dek) return null\n\n return decryptWithAesGcm(stored, dek.key)\n}\n\nexport type CreateApiKeyInput = {\n name: string\n description?: string | null\n tenantId?: string | null\n organizationId?: string | null\n roles?: string[]\n expiresAt?: Date | null\n createdBy?: string | null\n}\n\nexport type ApiKeyWithSecret = {\n record: ApiKey\n secret: string\n}\n\nexport function generateApiKeySecret(): { secret: string; prefix: string } {\n const short = randomBytes(4).toString('hex')\n const body = randomBytes(24).toString('hex')\n const secret = `omk_${short}.${body}`\n const prefix = secret.slice(0, 12)\n return { secret, prefix }\n}\n\nexport async function hashApiKey(secret: string): Promise<string> {\n return hash(secret, BCRYPT_COST)\n}\n\nexport async function verifyApiKey(secret: string, keyHash: string): Promise<boolean> {\n return compare(secret, keyHash)\n}\n\nexport async function createApiKey(\n em: EntityManager,\n input: CreateApiKeyInput,\n opts: {\n rbac?: RbacService\n authorizeRoles?: (roleIds: readonly string[]) => Promise<void> | void\n } = {},\n): Promise<ApiKeyWithSecret> {\n const { secret, prefix } = generateApiKeySecret()\n const keyHash = await hashApiKey(secret)\n const roleIds = Array.from(new Set((input.roles ?? []).filter(Boolean))).sort((left, right) => left.localeCompare(right))\n let record!: ApiKey\n await withAtomicFlush(em, [async () => {\n await lockRoleWriterAuthorizationState(em, roleIds)\n await opts.authorizeRoles?.(roleIds)\n record = em.create(ApiKey, {\n name: input.name,\n description: input.description ?? null,\n tenantId: input.tenantId ?? null,\n organizationId: input.organizationId ?? null,\n keyHash,\n keyPrefix: prefix,\n rolesJson: roleIds,\n createdBy: input.createdBy ?? null,\n expiresAt: input.expiresAt ?? null,\n createdAt: new Date(),\n })\n em.persist(record)\n }], { transaction: true, label: 'api_keys.create' })\n if (opts.rbac) {\n await opts.rbac.invalidateUserCache(`api_key:${record.id}`)\n }\n return { record, secret }\n}\n\nexport async function deleteApiKey(\n em: EntityManager,\n id: string,\n opts: { rbac?: RbacService; authorize?: (record: ApiKey) => Promise<void> | void } = {},\n): Promise<boolean> {\n let deletedId: string | null = null\n await withAtomicFlush(em, [async () => {\n const records = await lockAuthorizationApiKeyRows(em, [id])\n const record = records.find((candidate) => String(candidate.id) === id)\n if (!record || record.deletedAt) return\n await opts.authorize?.(record)\n record.deletedAt = new Date()\n await em.persist(record).flush()\n deletedId = String(record.id)\n }], { transaction: true, label: 'api_keys.delete' })\n if (!deletedId) return false\n getSharedApiKeyAuthCache().invalidateByKeyId(deletedId)\n if (opts.rbac) {\n await opts.rbac.invalidateUserCache(`api_key:${deletedId}`)\n }\n return true\n}\n\nexport async function findApiKeyBySecret(em: EntityManager, secret: string): Promise<ApiKey | null> {\n if (!secret) return null\n // Extract prefix from the secret for fast candidate lookup\n const prefix = secret.slice(0, 12)\n // Find candidates by prefix (fast index lookup). Invariant: the unique keyPrefix\n // constraint plus the deletedAt: null filter keep this to at most one live row, so\n // the bcrypt loop below stays bounded. Do not widen the prefix space or relax either\n // filter without re-evaluating that cost (see #3812).\n const candidates = await em.find(ApiKey, { keyPrefix: prefix, deletedAt: null })\n // Verify each candidate with bcrypt until we find a match\n for (const candidate of candidates) {\n if (candidate.expiresAt && candidate.expiresAt.getTime() < Date.now()) continue\n const isValid = await verifyApiKey(secret, candidate.keyHash)\n if (isValid) return candidate\n }\n return null\n}\n\n// =============================================================================\n// Session-scoped API Keys (for AI Chat ephemeral authorization)\n// =============================================================================\n\nexport type CreateSessionApiKeyInput = {\n sessionToken: string\n userId: string\n userRoles: string[]\n tenantId?: string | null\n organizationId?: string | null\n ttlMinutes?: number\n}\n\n/**\n * Generate a unique session token for ephemeral API keys.\n * Format: sess_{32 hex chars}\n */\nexport function generateSessionToken(): string {\n return `sess_${randomBytes(16).toString('hex')}`\n}\n\n/**\n * Hash a session token for storage/lookup (#2254). The session token is a bearer credential \u2014\n * it unlocks the decrypted API key secret and the user's ACL on the MCP server \u2014 so, mirroring\n * `customer_accounts`' `hashToken`, only its hash is ever persisted. 128 bits of entropy from\n * `generateSessionToken` is sufficient for a plain SHA-256 (no bcrypt cost needed, and a slow\n * hash would just add latency to every MCP tool call that resolves a session).\n */\nexport function hashSessionToken(sessionToken: string): string {\n return createHash('sha256').update(sessionToken).digest('hex')\n}\n\n/**\n * Create an ephemeral API key scoped to a chat session.\n * The key inherits the user's roles and expires after ttlMinutes (default 30).\n * The API key secret is encrypted and stored so it can be recovered for API calls.\n */\nexport async function createSessionApiKey(\n em: EntityManager,\n input: CreateSessionApiKeyInput\n): Promise<{ keyId: string; secret: string; sessionToken: string }> {\n const { secret, prefix } = generateApiKeySecret()\n const ttl = input.ttlMinutes ?? 30\n const expiresAt = new Date(Date.now() + ttl * 60 * 1000)\n const keyHash = await hashApiKey(secret)\n\n // Encrypt the secret for later retrieval (used by MCP server for API calls)\n const encryptedSecret = await encryptSessionSecret(secret, input.tenantId ?? null)\n\n // Hash before storage (#2254) \u2014 the row (and its `name` column, previously embedding the raw\n // token for operator visibility) must never carry the live bearer credential in the clear.\n const sessionTokenHash = hashSessionToken(input.sessionToken)\n\n const roleIds = Array.from(new Set(input.userRoles.filter(Boolean))).sort((left, right) => left.localeCompare(right))\n let record!: ApiKey\n await withAtomicFlush(em, [async () => {\n await lockRoleWriterAuthorizationState(em, roleIds)\n record = em.create(ApiKey, {\n name: `__session_${sessionTokenHash.slice(0, 12)}__`,\n description: 'Ephemeral session API key for AI chat',\n tenantId: input.tenantId ?? null,\n organizationId: input.organizationId ?? null,\n keyHash,\n keyPrefix: prefix,\n rolesJson: roleIds,\n createdBy: input.userId,\n sessionTokenHash,\n sessionUserId: input.userId,\n sessionSecretEncrypted: encryptedSecret,\n expiresAt,\n createdAt: new Date(),\n })\n em.persist(record)\n }], { transaction: true, label: 'api_keys.session.create' })\n\n return {\n keyId: record.id,\n secret,\n sessionToken: input.sessionToken,\n }\n}\n\n/**\n * Find an API key by its session token.\n * Returns null if not found, expired, or deleted.\n */\nexport async function findApiKeyBySessionToken(\n em: EntityManager,\n sessionToken: string\n): Promise<ApiKey | null> {\n if (!sessionToken) return null\n\n // Looked up by hash, never by the raw token (#2254).\n const sessionTokenHash = hashSessionToken(sessionToken)\n let record = await em.findOne(ApiKey, { sessionTokenHash, deletedAt: null })\n\n if (!record) {\n // Version-skew fallback (#7304): during a rolling deploy/rollback window the app and the\n // separate MCP process can briefly run different code versions. A row minted by\n // pre-#2254 code in that window carries only the plaintext token (sessionTokenHash is\n // still null) \u2014 fall back to it so the session still resolves instead of answering a\n // false SESSION_EXPIRED, then self-heal the row onto the hash immediately so this is the\n // only lookup that ever sees the plaintext, and the window closes as soon as each\n // outstanding legacy row is touched once.\n const legacyRecord = await em.findOne(ApiKey, { sessionToken, deletedAt: null })\n if (legacyRecord) {\n legacyRecord.sessionTokenHash = sessionTokenHash\n legacyRecord.sessionToken = null\n await em.persist(legacyRecord).flush()\n record = legacyRecord\n }\n }\n\n if (!record) return null\n if (record.expiresAt && record.expiresAt.getTime() < Date.now()) return null\n\n return record\n}\n\n/**\n * Bind an OpenCode session id to the api_key row that owns this chat session.\n *\n * Called by the chat dispatcher the first time we see the `done` event for a\n * freshly minted session token. From that point on,\n * `findApiKeyByOpencodeSessionId(em, opencodeSessionId)` returns the same row,\n * which the ai-assistant runtime uses to assert ownership on every resume.\n *\n * Throws when the session token has been deleted/expired, and when the api_key\n * row is already bound to a DIFFERENT OpenCode session (defensive: this should\n * never happen in practice because each chat mints a new session token, but we\n * fail closed instead of silently overwriting).\n *\n * Idempotent when the row is already bound to the same OpenCode session id.\n */\nexport async function bindOpencodeSessionToApiKey(\n em: EntityManager,\n sessionToken: string,\n opencodeSessionId: string\n): Promise<void> {\n if (!sessionToken) throw new Error('Session token not found or expired')\n if (!opencodeSessionId) throw new Error('OpenCode session id is required')\n\n const row = await findApiKeyBySessionToken(em, sessionToken)\n if (!row) throw new Error('Session token not found or expired')\n\n if (row.opencodeSessionId === opencodeSessionId) return\n if (row.opencodeSessionId && row.opencodeSessionId !== opencodeSessionId) {\n throw new Error('Session token already bound to a different OpenCode session')\n }\n\n row.opencodeSessionId = opencodeSessionId\n await em.persist(row).flush()\n}\n\n/**\n * Find an api_key row by its bound OpenCode session id.\n *\n * Returns null if no active row matches, or if the matched row is expired\n * (same contract as `findApiKeyBySessionToken`). Uses\n * `findOneWithDecryption` so encrypted-at-rest fields on the row are decrypted\n * before the ai-assistant runtime inspects `sessionUserId` / `tenantId` /\n * `organizationId` for the ownership check.\n */\nexport async function findApiKeyByOpencodeSessionId(\n em: EntityManager,\n opencodeSessionId: string\n): Promise<ApiKey | null> {\n if (!opencodeSessionId) return null\n\n const record = await findOneWithDecryption(\n em,\n ApiKey,\n { opencodeSessionId, deletedAt: null } as any,\n )\n\n if (!record) return null\n if (record.expiresAt && record.expiresAt.getTime() < Date.now()) return null\n\n return record\n}\n\n/**\n * Find a session API key with its decrypted secret.\n * Returns null if not found, expired, deleted, or decryption fails.\n * This is used by the MCP server to recover the API key secret for making\n * authenticated API calls on behalf of the user.\n */\nexport async function findSessionApiKeyWithSecret(\n em: EntityManager,\n sessionToken: string\n): Promise<{ key: ApiKey; secret: string } | null> {\n const record = await findApiKeyBySessionToken(em, sessionToken)\n if (!record) return null\n\n // If no encrypted secret stored, cannot recover\n if (!record.sessionSecretEncrypted) {\n logger.warn('Session key has no encrypted secret', { apiKeyId: record.id })\n return null\n }\n\n // Decrypt the secret\n const secret = await decryptSessionSecret(record.sessionSecretEncrypted, record.tenantId ?? null)\n if (!secret) {\n logger.warn('Failed to decrypt session secret', { apiKeyId: record.id })\n return null\n }\n\n return { key: record, secret }\n}\n\n/**\n * Delete an ephemeral API key by its session token.\n */\nexport async function deleteSessionApiKey(\n em: EntityManager,\n sessionToken: string\n): Promise<void> {\n // Looked up and re-checked by hash, never by the raw token (#2254). Deliberately does not\n // call findApiKeyBySessionToken: that helper also drops already-expired rows, but deleting\n // an expired-but-not-yet-cleaned-up row must still work here.\n const sessionTokenHash = hashSessionToken(sessionToken)\n let candidate = await em.findOne(ApiKey, { sessionTokenHash, deletedAt: null })\n if (!candidate) {\n // Version-skew fallback (#7304): see findApiKeyBySessionToken for the full rationale \u2014 a\n // row minted by pre-#2254 code during a deploy window is plaintext-only until touched.\n const legacyCandidate = await em.findOne(ApiKey, { sessionToken, deletedAt: null })\n if (legacyCandidate) {\n legacyCandidate.sessionTokenHash = sessionTokenHash\n legacyCandidate.sessionToken = null\n await em.persist(legacyCandidate).flush()\n candidate = legacyCandidate\n }\n }\n if (!candidate) return\n let deletedId: string | null = null\n await withAtomicFlush(em, [async () => {\n const records = await lockAuthorizationApiKeyRows(em, [String(candidate.id)])\n const record = records.find((entry) => (\n String(entry.id) === String(candidate.id)\n && entry.sessionTokenHash === sessionTokenHash\n && !entry.deletedAt\n ))\n if (!record) return\n record.deletedAt = new Date()\n await em.persist(record).flush()\n deletedId = String(record.id)\n }], { transaction: true, label: 'api_keys.session.delete' })\n if (deletedId) getSharedApiKeyAuthCache().invalidateByKeyId(deletedId)\n}\n\n/**\n * Execute a function with a one-time API key\n *\n * Creates a temporary API key, executes the function, and deletes the key.\n * Perfect for workflow activities that need authenticated access without\n * storing long-lived credentials.\n *\n * @param em - Entity manager\n * @param input - API key configuration\n * @param fn - Function to execute with the API key secret\n * @returns Result of the function\n */\nconst ONETIME_KEY_MAX_TTL_MS = 5 * 60 * 1000\n\nexport async function withOnetimeApiKey<T>(\n em: EntityManager,\n input: CreateApiKeyInput,\n fn: (secret: string) => Promise<T>\n): Promise<T> {\n const maxExpiresAt = new Date(Date.now() + ONETIME_KEY_MAX_TTL_MS)\n const safeExpiresAt = input.expiresAt && input.expiresAt < maxExpiresAt\n ? input.expiresAt\n : maxExpiresAt\n\n const { record, secret } = await createApiKey(em, {\n ...input,\n name: input.name || '__onetime__',\n description: input.description || 'One-time API key',\n expiresAt: safeExpiresAt,\n })\n\n try {\n const result = await fn(secret)\n return result\n } finally {\n try {\n await deleteApiKey(em, String(record.id))\n } catch (error) {\n logger.error('Failed to soft-delete one-time API key', { err: error })\n }\n }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": ["encrypted"]
7
7
  }
@@ -110,6 +110,14 @@ class OcrService {
110
110
  guidance = "Rate limit exceeded. Please try again later.";
111
111
  break;
112
112
  default:
113
+ if (err?.name === "TimeoutError") {
114
+ guidance = `OCR request timed out: ${rawMessage}`;
115
+ break;
116
+ }
117
+ if (err?.name === "AbortError") {
118
+ guidance = `OCR request was aborted: ${rawMessage}`;
119
+ break;
120
+ }
113
121
  guidance = rawMessage.includes("https://") ? rawMessage : `${rawMessage}. Check OPENAI_API_KEY.`;
114
122
  }
115
123
  const wrapped = new Error(`[attachments.ocr] ${guidance}`);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/attachments/lib/ocrService.ts"],
4
- "sourcesContent": ["import { generateText } from 'ai'\nimport { createOpenAI } from '@ai-sdk/openai'\nimport fs from 'fs/promises'\nimport path from 'path'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { resolveOcrMaxOutputTokens, resolveOcrPageTimeoutMs } from './ocrLimits'\n\nconst logger = createLogger('attachments').child({ component: 'ocr-service' })\n\nexport type OcrServiceOptions = {\n apiKey?: string\n model?: string\n}\n\nexport type OcrInput = {\n filePath: string\n mimeType: string | null\n model?: string\n}\n\nexport type OcrResult = {\n content: string\n pageCount?: number\n processingTimeMs: number\n}\n\nconst DEFAULT_MODEL = 'gpt-4o'\nconst OCR_IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.tiff']\n\nconst DEFAULT_OCR_PROMPT = `Extract all text content from this image. Preserve the structure and formatting where possible. Output the text in markdown format. If there are tables, preserve them as markdown tables. If there is no text visible, respond with an empty string.`\n\nfunction isImageMimeType(mimeType: string | null, filePath?: string): boolean {\n const normalized = (mimeType || '').toLowerCase()\n if (normalized.startsWith('image/')) return true\n if (filePath) {\n const ext = path.extname(filePath).toLowerCase()\n return OCR_IMAGE_EXTENSIONS.includes(ext)\n }\n return false\n}\n\nfunction isPdfMimeType(mimeType: string | null, filePath?: string): boolean {\n const normalized = (mimeType || '').toLowerCase()\n if (normalized === 'application/pdf') return true\n if (filePath) {\n const ext = path.extname(filePath).toLowerCase()\n return ext === '.pdf'\n }\n return false\n}\n\nfunction getImageMediaType(mimeType: string | null, filePath: string): string {\n if (mimeType && mimeType.startsWith('image/')) {\n return mimeType\n }\n const ext = path.extname(filePath).toLowerCase()\n const mimeMap: Record<string, string> = {\n '.png': 'image/png',\n '.jpg': 'image/jpeg',\n '.jpeg': 'image/jpeg',\n '.gif': 'image/gif',\n '.bmp': 'image/bmp',\n '.webp': 'image/webp',\n '.tiff': 'image/tiff',\n }\n return mimeMap[ext] || 'image/png'\n}\n\nexport class OcrService {\n private readonly apiKey: string | null\n private readonly defaultModel: string\n private client: ReturnType<typeof createOpenAI> | null = null\n\n constructor(opts: OcrServiceOptions = {}) {\n this.apiKey = opts.apiKey ?? process.env.OPENAI_API_KEY ?? null\n this.defaultModel = opts.model ?? process.env.OCR_MODEL ?? DEFAULT_MODEL\n }\n\n get available(): boolean {\n return Boolean(this.apiKey)\n }\n\n private ensureClient() {\n if (!this.apiKey) {\n throw new Error('[attachments.ocr] Missing OPENAI_API_KEY environment variable')\n }\n if (!this.client) {\n this.client = createOpenAI({ apiKey: this.apiKey })\n }\n return this.client\n }\n\n private async generateTextFromImageBuffer(params: {\n imageBuffer: Buffer\n mediaType: string\n model: string\n }): Promise<string> {\n const { imageBuffer, mediaType, model } = params\n const client = this.ensureClient()\n const base64 = imageBuffer.toString('base64')\n const dataUrl = `data:${mediaType};base64,${base64}`\n\n try {\n const result = await generateText({\n model: client(model),\n abortSignal: AbortSignal.timeout(resolveOcrPageTimeoutMs()),\n maxRetries: 0,\n maxOutputTokens: resolveOcrMaxOutputTokens(),\n messages: [\n {\n role: 'user',\n content: [\n {\n type: 'image',\n image: dataUrl,\n },\n {\n type: 'text',\n text: process.env.OCR_DEFAULT_PROMPT ?? DEFAULT_OCR_PROMPT,\n },\n ],\n },\n ],\n })\n\n return result.text.trim()\n } catch (err: any) {\n const statusCandidate =\n err?.statusCode ?? err?.status ?? err?.response?.status ?? err?.response?.statusCode\n const status =\n typeof statusCandidate === 'number'\n ? Number.isFinite(statusCandidate) ? statusCandidate : undefined\n : typeof statusCandidate === 'string'\n ? Number.parseInt(statusCandidate, 10)\n : undefined\n const apiError = err?.data?.error ?? err?.body?.error ?? err?.response?.data?.error\n const apiMessage = apiError?.message ?? err?.response?.data?.message\n const apiCode = typeof apiError?.code === 'string' ? apiError.code : undefined\n const rawMessage = typeof apiMessage === 'string'\n ? apiMessage\n : (typeof err?.message === 'string' ? err.message : 'OCR request failed')\n\n let guidance: string\n switch (apiCode) {\n case 'insufficient_quota':\n guidance = 'OpenAI usage quota exceeded. Please review your plan and billing.'\n break\n case 'invalid_api_key':\n guidance = 'Invalid OpenAI API key. Update the key and retry.'\n break\n case 'account_deactivated':\n guidance = 'OpenAI account is disabled. Contact OpenAI support or provide a different key.'\n break\n case 'rate_limit_exceeded':\n guidance = 'Rate limit exceeded. Please try again later.'\n break\n default:\n guidance = rawMessage.includes('https://')\n ? rawMessage\n : `${rawMessage}. Check OPENAI_API_KEY.`\n }\n\n const wrapped = new Error(`[attachments.ocr] ${guidance}`)\n if (typeof status === 'number' && Number.isFinite(status)) {\n const normalizedStatus = status === 401 || status === 403 ? 502 : status\n if (normalizedStatus >= 400 && normalizedStatus < 600) {\n (wrapped as any).status = normalizedStatus\n }\n }\n if (apiCode) {\n (wrapped as any).code = apiCode\n }\n (wrapped as any).cause = err\n throw wrapped\n }\n }\n\n async processImage(input: OcrInput): Promise<OcrResult> {\n const startTime = Date.now()\n const { filePath, mimeType, model } = input\n const resolvedModel = model ?? this.defaultModel\n const imageBuffer = await fs.readFile(filePath)\n\n return {\n content: await this.generateTextFromImageBuffer({\n imageBuffer,\n mediaType: getImageMediaType(mimeType, filePath),\n model: resolvedModel,\n }),\n processingTimeMs: Date.now() - startTime,\n }\n }\n\n async processPdf(input: OcrInput): Promise<OcrResult> {\n const startTime = Date.now()\n const { filePath, model } = input\n const resolvedModel = model ?? this.defaultModel\n const { preparePdfPagesForOcr } = await import('./pdfProcessing')\n const preparedPdf = await preparePdfPagesForOcr(filePath)\n\n if (preparedPdf.pageCount === 0) {\n return {\n content: '',\n pageCount: 0,\n processingTimeMs: Date.now() - startTime,\n }\n }\n\n const pageContents: string[] = []\n\n for (const page of preparedPdf.pages) {\n try {\n let pageContent = page.extractedText\n\n if (!pageContent && page.imageBuffer) {\n pageContent = await this.generateTextFromImageBuffer({\n imageBuffer: page.imageBuffer,\n mediaType: 'image/png',\n model: resolvedModel,\n })\n }\n\n if (pageContent) {\n if (preparedPdf.pageCount > 1) {\n pageContents.push(`--- Page ${page.pageNumber} ---\\n\\n${pageContent}`)\n } else {\n pageContents.push(pageContent)\n }\n }\n } catch (error) {\n logger.error('Failed to process PDF page', { pageNumber: page.pageNumber, err: error })\n }\n }\n\n return {\n content: pageContents.join('\\n\\n'),\n pageCount: preparedPdf.pageCount,\n processingTimeMs: Date.now() - startTime,\n }\n }\n\n async processFile(input: OcrInput): Promise<OcrResult | null> {\n const { filePath, mimeType } = input\n\n if (isPdfMimeType(mimeType, filePath)) {\n logger.info('Processing PDF attachment via pdfjs-dist')\n return this.processPdf(input)\n }\n\n if (isImageMimeType(mimeType, filePath)) {\n logger.info('Processing image attachment via LLM OCR')\n return this.processImage(input)\n }\n\n logger.info('Unsupported file type for OCR', { mimeType: mimeType || 'unknown' })\n return null\n }\n}\n\nexport function shouldUseLlmOcr(mimeType: string | null, fileName: string): boolean {\n if (isPdfMimeType(mimeType, fileName)) return true\n return isImageMimeType(mimeType, fileName)\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { generateText } from 'ai'\nimport { createOpenAI } from '@ai-sdk/openai'\nimport fs from 'fs/promises'\nimport path from 'path'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { resolveOcrMaxOutputTokens, resolveOcrPageTimeoutMs } from './ocrLimits'\n\nconst logger = createLogger('attachments').child({ component: 'ocr-service' })\n\nexport type OcrServiceOptions = {\n apiKey?: string\n model?: string\n}\n\nexport type OcrInput = {\n filePath: string\n mimeType: string | null\n model?: string\n}\n\nexport type OcrResult = {\n content: string\n pageCount?: number\n processingTimeMs: number\n}\n\nconst DEFAULT_MODEL = 'gpt-4o'\nconst OCR_IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.tiff']\n\nconst DEFAULT_OCR_PROMPT = `Extract all text content from this image. Preserve the structure and formatting where possible. Output the text in markdown format. If there are tables, preserve them as markdown tables. If there is no text visible, respond with an empty string.`\n\nfunction isImageMimeType(mimeType: string | null, filePath?: string): boolean {\n const normalized = (mimeType || '').toLowerCase()\n if (normalized.startsWith('image/')) return true\n if (filePath) {\n const ext = path.extname(filePath).toLowerCase()\n return OCR_IMAGE_EXTENSIONS.includes(ext)\n }\n return false\n}\n\nfunction isPdfMimeType(mimeType: string | null, filePath?: string): boolean {\n const normalized = (mimeType || '').toLowerCase()\n if (normalized === 'application/pdf') return true\n if (filePath) {\n const ext = path.extname(filePath).toLowerCase()\n return ext === '.pdf'\n }\n return false\n}\n\nfunction getImageMediaType(mimeType: string | null, filePath: string): string {\n if (mimeType && mimeType.startsWith('image/')) {\n return mimeType\n }\n const ext = path.extname(filePath).toLowerCase()\n const mimeMap: Record<string, string> = {\n '.png': 'image/png',\n '.jpg': 'image/jpeg',\n '.jpeg': 'image/jpeg',\n '.gif': 'image/gif',\n '.bmp': 'image/bmp',\n '.webp': 'image/webp',\n '.tiff': 'image/tiff',\n }\n return mimeMap[ext] || 'image/png'\n}\n\nexport class OcrService {\n private readonly apiKey: string | null\n private readonly defaultModel: string\n private client: ReturnType<typeof createOpenAI> | null = null\n\n constructor(opts: OcrServiceOptions = {}) {\n this.apiKey = opts.apiKey ?? process.env.OPENAI_API_KEY ?? null\n this.defaultModel = opts.model ?? process.env.OCR_MODEL ?? DEFAULT_MODEL\n }\n\n get available(): boolean {\n return Boolean(this.apiKey)\n }\n\n private ensureClient() {\n if (!this.apiKey) {\n throw new Error('[attachments.ocr] Missing OPENAI_API_KEY environment variable')\n }\n if (!this.client) {\n this.client = createOpenAI({ apiKey: this.apiKey })\n }\n return this.client\n }\n\n private async generateTextFromImageBuffer(params: {\n imageBuffer: Buffer\n mediaType: string\n model: string\n }): Promise<string> {\n const { imageBuffer, mediaType, model } = params\n const client = this.ensureClient()\n const base64 = imageBuffer.toString('base64')\n const dataUrl = `data:${mediaType};base64,${base64}`\n\n try {\n const result = await generateText({\n model: client(model),\n abortSignal: AbortSignal.timeout(resolveOcrPageTimeoutMs()),\n maxRetries: 0,\n maxOutputTokens: resolveOcrMaxOutputTokens(),\n messages: [\n {\n role: 'user',\n content: [\n {\n type: 'image',\n image: dataUrl,\n },\n {\n type: 'text',\n text: process.env.OCR_DEFAULT_PROMPT ?? DEFAULT_OCR_PROMPT,\n },\n ],\n },\n ],\n })\n\n return result.text.trim()\n } catch (err: any) {\n const statusCandidate =\n err?.statusCode ?? err?.status ?? err?.response?.status ?? err?.response?.statusCode\n const status =\n typeof statusCandidate === 'number'\n ? Number.isFinite(statusCandidate) ? statusCandidate : undefined\n : typeof statusCandidate === 'string'\n ? Number.parseInt(statusCandidate, 10)\n : undefined\n const apiError = err?.data?.error ?? err?.body?.error ?? err?.response?.data?.error\n const apiMessage = apiError?.message ?? err?.response?.data?.message\n const apiCode = typeof apiError?.code === 'string' ? apiError.code : undefined\n const rawMessage = typeof apiMessage === 'string'\n ? apiMessage\n : (typeof err?.message === 'string' ? err.message : 'OCR request failed')\n\n let guidance: string\n switch (apiCode) {\n case 'insufficient_quota':\n guidance = 'OpenAI usage quota exceeded. Please review your plan and billing.'\n break\n case 'invalid_api_key':\n guidance = 'Invalid OpenAI API key. Update the key and retry.'\n break\n case 'account_deactivated':\n guidance = 'OpenAI account is disabled. Contact OpenAI support or provide a different key.'\n break\n case 'rate_limit_exceeded':\n guidance = 'Rate limit exceeded. Please try again later.'\n break\n default:\n if (err?.name === 'TimeoutError') {\n guidance = `OCR request timed out: ${rawMessage}`\n break\n }\n if (err?.name === 'AbortError') {\n guidance = `OCR request was aborted: ${rawMessage}`\n break\n }\n guidance = rawMessage.includes('https://')\n ? rawMessage\n : `${rawMessage}. Check OPENAI_API_KEY.`\n }\n\n const wrapped = new Error(`[attachments.ocr] ${guidance}`)\n if (typeof status === 'number' && Number.isFinite(status)) {\n const normalizedStatus = status === 401 || status === 403 ? 502 : status\n if (normalizedStatus >= 400 && normalizedStatus < 600) {\n (wrapped as any).status = normalizedStatus\n }\n }\n if (apiCode) {\n (wrapped as any).code = apiCode\n }\n (wrapped as any).cause = err\n throw wrapped\n }\n }\n\n async processImage(input: OcrInput): Promise<OcrResult> {\n const startTime = Date.now()\n const { filePath, mimeType, model } = input\n const resolvedModel = model ?? this.defaultModel\n const imageBuffer = await fs.readFile(filePath)\n\n return {\n content: await this.generateTextFromImageBuffer({\n imageBuffer,\n mediaType: getImageMediaType(mimeType, filePath),\n model: resolvedModel,\n }),\n processingTimeMs: Date.now() - startTime,\n }\n }\n\n async processPdf(input: OcrInput): Promise<OcrResult> {\n const startTime = Date.now()\n const { filePath, model } = input\n const resolvedModel = model ?? this.defaultModel\n const { preparePdfPagesForOcr } = await import('./pdfProcessing')\n const preparedPdf = await preparePdfPagesForOcr(filePath)\n\n if (preparedPdf.pageCount === 0) {\n return {\n content: '',\n pageCount: 0,\n processingTimeMs: Date.now() - startTime,\n }\n }\n\n const pageContents: string[] = []\n\n for (const page of preparedPdf.pages) {\n try {\n let pageContent = page.extractedText\n\n if (!pageContent && page.imageBuffer) {\n pageContent = await this.generateTextFromImageBuffer({\n imageBuffer: page.imageBuffer,\n mediaType: 'image/png',\n model: resolvedModel,\n })\n }\n\n if (pageContent) {\n if (preparedPdf.pageCount > 1) {\n pageContents.push(`--- Page ${page.pageNumber} ---\\n\\n${pageContent}`)\n } else {\n pageContents.push(pageContent)\n }\n }\n } catch (error) {\n logger.error('Failed to process PDF page', { pageNumber: page.pageNumber, err: error })\n }\n }\n\n return {\n content: pageContents.join('\\n\\n'),\n pageCount: preparedPdf.pageCount,\n processingTimeMs: Date.now() - startTime,\n }\n }\n\n async processFile(input: OcrInput): Promise<OcrResult | null> {\n const { filePath, mimeType } = input\n\n if (isPdfMimeType(mimeType, filePath)) {\n logger.info('Processing PDF attachment via pdfjs-dist')\n return this.processPdf(input)\n }\n\n if (isImageMimeType(mimeType, filePath)) {\n logger.info('Processing image attachment via LLM OCR')\n return this.processImage(input)\n }\n\n logger.info('Unsupported file type for OCR', { mimeType: mimeType || 'unknown' })\n return null\n }\n}\n\nexport function shouldUseLlmOcr(mimeType: string | null, fileName: string): boolean {\n if (isPdfMimeType(mimeType, fileName)) return true\n return isImageMimeType(mimeType, fileName)\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -72,6 +72,8 @@ async function POST(req) {
72
72
  const res = buildSafeRedirectResponse(req, "/login");
73
73
  res.cookies.set("auth_token", "", { path: "/", maxAge: 0 });
74
74
  res.cookies.set("session_token", "", { path: "/", maxAge: 0 });
75
+ res.cookies.set("om_selected_org", "", { path: "/", maxAge: 0 });
76
+ res.cookies.set("om_selected_tenant", "", { path: "/", maxAge: 0 });
75
77
  return res;
76
78
  }
77
79
  const metadata = {
@@ -83,7 +85,7 @@ const openApi = {
83
85
  methods: {
84
86
  POST: {
85
87
  summary: "Invalidate session and redirect",
86
- 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.",
88
+ 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.",
87
89
  responses: [
88
90
  { status: 307, description: "Redirect to login after successful logout", mediaType: "text/html" }
89
91
  ]
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/auth/api/logout.ts"],
4
- "sourcesContent": ["import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { AuthService } from '@open-mercato/core/modules/auth/services/authService'\nimport { verifyJwt } from '@open-mercato/shared/lib/auth/jwt'\nimport { buildSafeRedirectResponse } from '@open-mercato/core/modules/auth/lib/requestRedirect'\nimport { emitAuthEvent } from '@open-mercato/core/modules/auth/events'\nimport { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'\n\ntype AuthTokenClaims = {\n userId: string | null\n sessionId: string | null\n tenantId: string | null\n organizationId: string | null\n}\n\nconst emptyAuthTokenClaims: AuthTokenClaims = Object.freeze({\n userId: null,\n sessionId: null,\n tenantId: null,\n organizationId: null,\n})\n\nfunction parseCookie(req: Request, name: string): string | null {\n const cookie = req.headers.get('cookie') || ''\n const m = cookie.match(new RegExp('(?:^|;\\\\s*)' + name + '=([^;]+)'))\n if (!m) return null\n try {\n return decodeURIComponent(m[1])\n } catch {\n return null\n }\n}\n\nfunction readStringClaim(payload: Record<string, unknown>, key: string): string | null {\n const value = payload[key]\n return typeof value === 'string' && value.length > 0 ? value : null\n}\n\nfunction extractAuthTokenClaims(token: string | null): AuthTokenClaims {\n if (!token) return emptyAuthTokenClaims\n try {\n const payload = verifyJwt(token) as Record<string, unknown> | null\n if (!payload || payload.type === 'customer') return emptyAuthTokenClaims\n return {\n userId: readStringClaim(payload, 'sub'),\n sessionId: readStringClaim(payload, 'sid'),\n tenantId: readStringClaim(payload, 'tenantId'),\n organizationId: readStringClaim(payload, 'orgId'),\n }\n } catch {\n return emptyAuthTokenClaims\n }\n}\n\nexport async function POST(req: Request) {\n const sessToken = parseCookie(req, 'session_token')\n const authToken = parseCookie(req, 'auth_token')\n if (!sessToken && !authToken) return buildSafeRedirectResponse(req, '/login')\n const claims = extractAuthTokenClaims(authToken)\n let sessionRevoked = false\n if (sessToken || claims.sessionId) {\n try {\n const c = await createRequestContainer()\n const auth = c.resolve<AuthService>('authService')\n if (claims.sessionId) {\n await auth.deleteSessionById(claims.sessionId)\n }\n if (sessToken) {\n await auth.deleteSessionByToken(sessToken)\n }\n sessionRevoked = true\n } catch (error) {\n getTelemetryRuntime()?.reportError(error, { module: 'auth', code: 'auth.logout_revoke_failed' })\n }\n }\n if (claims.userId) {\n void emitAuthEvent('auth.logout', {\n id: claims.userId,\n tenantId: claims.tenantId,\n organizationId: claims.organizationId,\n sessionId: claims.sessionId,\n sessionRevoked,\n at: new Date().toISOString(),\n }, { persistent: true }).catch(() => undefined)\n }\n const res = buildSafeRedirectResponse(req, '/login')\n res.cookies.set('auth_token', '', { path: '/', maxAge: 0 })\n res.cookies.set('session_token', '', { path: '/', maxAge: 0 })\n return res\n}\n\nexport const metadata = {\n POST: { requireAuth: false },\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Authentication & Accounts',\n summary: 'Log out current session',\n methods: {\n POST: {\n summary: 'Invalidate session and redirect',\n 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.',\n responses: [\n { status: 307, description: 'Redirect to login after successful logout', mediaType: 'text/html' },\n ],\n },\n },\n}\n"],
5
- "mappings": "AACA,SAAS,8BAA8B;AAEvC,SAAS,iBAAiB;AAC1B,SAAS,iCAAiC;AAC1C,SAAS,qBAAqB;AAC9B,SAAS,2BAA2B;AASpC,MAAM,uBAAwC,OAAO,OAAO;AAAA,EAC1D,QAAQ;AAAA,EACR,WAAW;AAAA,EACX,UAAU;AAAA,EACV,gBAAgB;AAClB,CAAC;AAED,SAAS,YAAY,KAAc,MAA6B;AAC9D,QAAM,SAAS,IAAI,QAAQ,IAAI,QAAQ,KAAK;AAC5C,QAAM,IAAI,OAAO,MAAM,IAAI,OAAO,gBAAgB,OAAO,UAAU,CAAC;AACpE,MAAI,CAAC,EAAG,QAAO;AACf,MAAI;AACF,WAAO,mBAAmB,EAAE,CAAC,CAAC;AAAA,EAChC,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,gBAAgB,SAAkC,KAA4B;AACrF,QAAM,QAAQ,QAAQ,GAAG;AACzB,SAAO,OAAO,UAAU,YAAY,MAAM,SAAS,IAAI,QAAQ;AACjE;AAEA,SAAS,uBAAuB,OAAuC;AACrE,MAAI,CAAC,MAAO,QAAO;AACnB,MAAI;AACF,UAAM,UAAU,UAAU,KAAK;AAC/B,QAAI,CAAC,WAAW,QAAQ,SAAS,WAAY,QAAO;AACpD,WAAO;AAAA,MACL,QAAQ,gBAAgB,SAAS,KAAK;AAAA,MACtC,WAAW,gBAAgB,SAAS,KAAK;AAAA,MACzC,UAAU,gBAAgB,SAAS,UAAU;AAAA,MAC7C,gBAAgB,gBAAgB,SAAS,OAAO;AAAA,IAClD;AAAA,EACF,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,eAAsB,KAAK,KAAc;AACvC,QAAM,YAAY,YAAY,KAAK,eAAe;AAClD,QAAM,YAAY,YAAY,KAAK,YAAY;AAC/C,MAAI,CAAC,aAAa,CAAC,UAAW,QAAO,0BAA0B,KAAK,QAAQ;AAC5E,QAAM,SAAS,uBAAuB,SAAS;AAC/C,MAAI,iBAAiB;AACrB,MAAI,aAAa,OAAO,WAAW;AACjC,QAAI;AACF,YAAM,IAAI,MAAM,uBAAuB;AACvC,YAAM,OAAO,EAAE,QAAqB,aAAa;AACjD,UAAI,OAAO,WAAW;AACpB,cAAM,KAAK,kBAAkB,OAAO,SAAS;AAAA,MAC/C;AACA,UAAI,WAAW;AACb,cAAM,KAAK,qBAAqB,SAAS;AAAA,MAC3C;AACA,uBAAiB;AAAA,IACnB,SAAS,OAAO;AACd,0BAAoB,GAAG,YAAY,OAAO,EAAE,QAAQ,QAAQ,MAAM,4BAA4B,CAAC;AAAA,IACjG;AAAA,EACF;AACA,MAAI,OAAO,QAAQ;AACjB,SAAK,cAAc,eAAe;AAAA,MAChC,IAAI,OAAO;AAAA,MACX,UAAU,OAAO;AAAA,MACjB,gBAAgB,OAAO;AAAA,MACvB,WAAW,OAAO;AAAA,MAClB;AAAA,MACA,KAAI,oBAAI,KAAK,GAAE,YAAY;AAAA,IAC7B,GAAG,EAAE,YAAY,KAAK,CAAC,EAAE,MAAM,MAAM,MAAS;AAAA,EAChD;AACA,QAAM,MAAM,0BAA0B,KAAK,QAAQ;AACnD,MAAI,QAAQ,IAAI,cAAc,IAAI,EAAE,MAAM,KAAK,QAAQ,EAAE,CAAC;AAC1D,MAAI,QAAQ,IAAI,iBAAiB,IAAI,EAAE,MAAM,KAAK,QAAQ,EAAE,CAAC;AAC7D,SAAO;AACT;AAEO,MAAM,WAAW;AAAA,EACtB,MAAM,EAAE,aAAa,MAAM;AAC7B;AAEO,MAAM,UAA2B;AAAA,EACtC,KAAK;AAAA,EACL,SAAS;AAAA,EACT,SAAS;AAAA,IACP,MAAM;AAAA,MACJ,SAAS;AAAA,MACT,aAAa;AAAA,MACb,WAAW;AAAA,QACT,EAAE,QAAQ,KAAK,aAAa,6CAA6C,WAAW,YAAY;AAAA,MAClG;AAAA,IACF;AAAA,EACF;AACF;",
4
+ "sourcesContent": ["import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { AuthService } from '@open-mercato/core/modules/auth/services/authService'\nimport { verifyJwt } from '@open-mercato/shared/lib/auth/jwt'\nimport { buildSafeRedirectResponse } from '@open-mercato/core/modules/auth/lib/requestRedirect'\nimport { emitAuthEvent } from '@open-mercato/core/modules/auth/events'\nimport { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'\n\ntype AuthTokenClaims = {\n userId: string | null\n sessionId: string | null\n tenantId: string | null\n organizationId: string | null\n}\n\nconst emptyAuthTokenClaims: AuthTokenClaims = Object.freeze({\n userId: null,\n sessionId: null,\n tenantId: null,\n organizationId: null,\n})\n\nfunction parseCookie(req: Request, name: string): string | null {\n const cookie = req.headers.get('cookie') || ''\n const m = cookie.match(new RegExp('(?:^|;\\\\s*)' + name + '=([^;]+)'))\n if (!m) return null\n try {\n return decodeURIComponent(m[1])\n } catch {\n return null\n }\n}\n\nfunction readStringClaim(payload: Record<string, unknown>, key: string): string | null {\n const value = payload[key]\n return typeof value === 'string' && value.length > 0 ? value : null\n}\n\nfunction extractAuthTokenClaims(token: string | null): AuthTokenClaims {\n if (!token) return emptyAuthTokenClaims\n try {\n const payload = verifyJwt(token) as Record<string, unknown> | null\n if (!payload || payload.type === 'customer') return emptyAuthTokenClaims\n return {\n userId: readStringClaim(payload, 'sub'),\n sessionId: readStringClaim(payload, 'sid'),\n tenantId: readStringClaim(payload, 'tenantId'),\n organizationId: readStringClaim(payload, 'orgId'),\n }\n } catch {\n return emptyAuthTokenClaims\n }\n}\n\nexport async function POST(req: Request) {\n const sessToken = parseCookie(req, 'session_token')\n const authToken = parseCookie(req, 'auth_token')\n if (!sessToken && !authToken) return buildSafeRedirectResponse(req, '/login')\n const claims = extractAuthTokenClaims(authToken)\n let sessionRevoked = false\n if (sessToken || claims.sessionId) {\n try {\n const c = await createRequestContainer()\n const auth = c.resolve<AuthService>('authService')\n if (claims.sessionId) {\n await auth.deleteSessionById(claims.sessionId)\n }\n if (sessToken) {\n await auth.deleteSessionByToken(sessToken)\n }\n sessionRevoked = true\n } catch (error) {\n getTelemetryRuntime()?.reportError(error, { module: 'auth', code: 'auth.logout_revoke_failed' })\n }\n }\n if (claims.userId) {\n void emitAuthEvent('auth.logout', {\n id: claims.userId,\n tenantId: claims.tenantId,\n organizationId: claims.organizationId,\n sessionId: claims.sessionId,\n sessionRevoked,\n at: new Date().toISOString(),\n }, { persistent: true }).catch(() => undefined)\n }\n const res = buildSafeRedirectResponse(req, '/login')\n res.cookies.set('auth_token', '', { path: '/', maxAge: 0 })\n res.cookies.set('session_token', '', { path: '/', maxAge: 0 })\n res.cookies.set('om_selected_org', '', { path: '/', maxAge: 0 })\n res.cookies.set('om_selected_tenant', '', { path: '/', maxAge: 0 })\n return res\n}\n\nexport const metadata = {\n POST: { requireAuth: false },\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Authentication & Accounts',\n summary: 'Log out current session',\n methods: {\n POST: {\n summary: 'Invalidate session and redirect',\n 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.',\n responses: [\n { status: 307, description: 'Redirect to login after successful logout', mediaType: 'text/html' },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }