@open-mercato/core 0.10.1-develop.7736.1.88e07906bf → 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 (49) 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/workflows/api/portal/tasks/[id]/complete/route.js +1 -1
  13. package/dist/modules/workflows/api/portal/tasks/[id]/complete/route.js.map +2 -2
  14. package/dist/modules/workflows/api/tasks/[id]/complete/route.js +7 -1
  15. package/dist/modules/workflows/api/tasks/[id]/complete/route.js.map +2 -2
  16. package/dist/modules/workflows/backend/tasks/[id]/page.js +2 -1
  17. package/dist/modules/workflows/backend/tasks/[id]/page.js.map +2 -2
  18. package/dist/modules/workflows/lib/instance-status.js +16 -0
  19. package/dist/modules/workflows/lib/instance-status.js.map +7 -0
  20. package/dist/modules/workflows/lib/task-handler.js +16 -8
  21. package/dist/modules/workflows/lib/task-handler.js.map +2 -2
  22. package/dist/modules/workflows/lib/task-sla.js +10 -0
  23. package/dist/modules/workflows/lib/task-sla.js.map +2 -2
  24. package/dist/modules/workflows/lib/workflow-executor.js +29 -1
  25. package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
  26. package/generated/entities/api_key/index.ts +1 -0
  27. package/generated/entity-fields-registry.ts +1 -0
  28. package/package.json +7 -7
  29. package/src/modules/api_keys/data/entities.ts +25 -1
  30. package/src/modules/api_keys/migrations/.snapshot-open-mercato.json +234 -68
  31. package/src/modules/api_keys/migrations/Migration20261012202110_api_keys.ts +47 -0
  32. package/src/modules/api_keys/services/apiKeyService.ts +55 -9
  33. package/src/modules/auth/i18n/de.json +3 -0
  34. package/src/modules/auth/i18n/en.json +3 -0
  35. package/src/modules/auth/i18n/es.json +3 -0
  36. package/src/modules/auth/i18n/ko.json +3 -0
  37. package/src/modules/auth/i18n/pl.json +3 -0
  38. package/src/modules/workflows/api/portal/tasks/[id]/complete/route.ts +5 -1
  39. package/src/modules/workflows/api/tasks/[id]/complete/route.ts +7 -1
  40. package/src/modules/workflows/backend/tasks/[id]/page.tsx +6 -2
  41. package/src/modules/workflows/i18n/de.json +2 -0
  42. package/src/modules/workflows/i18n/en.json +2 -0
  43. package/src/modules/workflows/i18n/es.json +2 -0
  44. package/src/modules/workflows/i18n/ko.json +2 -0
  45. package/src/modules/workflows/i18n/pl.json +2 -0
  46. package/src/modules/workflows/lib/instance-status.ts +29 -0
  47. package/src/modules/workflows/lib/task-handler.ts +18 -10
  48. package/src/modules/workflows/lib/task-sla.ts +13 -0
  49. 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
  }
@@ -96,7 +96,7 @@ async function POST(request, { params }) {
96
96
  { status: 400 }
97
97
  );
98
98
  }
99
- if (code === "TASK_NOT_FOUND" || code === "TASK_ASSIGNED_TO_ANOTHER_USER") {
99
+ if (code === "TASK_NOT_FOUND" || code === "TASK_ASSIGNED_TO_ANOTHER_USER" || code === "WORKFLOW_NOT_ACTIVE") {
100
100
  return NextResponse.json(PORTAL_TASK_REFUSAL.body, { status: PORTAL_TASK_REFUSAL.status });
101
101
  }
102
102
  logger.error("Error completing portal task", { err: error });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../../src/modules/workflows/api/portal/tasks/%5Bid%5D/complete/route.ts"],
4
- "sourcesContent": ["/**\n * Portal User Task completion API\n *\n * - POST /api/workflows/portal/tasks/[id]/complete\n *\n * `actable` is the gate, and on the portal branch `actable` is true only for the\n * assignee themselves: a portal admin who can READ a company member's task gets\n * `visible` without `actable` and is refused here. The refusal is a 404, same as\n * every other portal refusal \u2014 telling an admin \"you may see this but not\n * finish it\" would disclose more than the read already did.\n */\n\nimport { NextRequest, NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport {\n getCustomerAuthFromRequest,\n requireCustomerFeature,\n} from '@open-mercato/core/modules/customer_accounts/lib/customerAuth'\nimport type { CustomerRbacService } from '@open-mercato/core/modules/customer_accounts/services/customerRbacService'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { UserTask } from '../../../../../data/entities'\nimport { serializeUserTask } from '../../../../tasks/serialize'\nimport type { TaskHandlerService } from '../../../../../lib/task-handler'\nimport {\n decidePortalTaskAccess,\n PORTAL_TASK_REFUSAL,\n PORTAL_TASKS_COMPLETE_FEATURE,\n resolvePortalTaskPrincipal,\n} from '../../../../../lib/portal-task-access'\nimport {\n workflowsTag,\n portalTaskCompleteRequestSchema,\n portalTaskCompleteResponseSchema,\n portalTaskErrorSchema,\n} from '../../../../openapi'\n\nconst logger = createLogger('workflows')\n\nexport const metadata: { requireAuth?: boolean } = { requireAuth: false }\n\nconst completeSchema = z.object({\n formData: z.record(z.string(), z.any()),\n comments: z.string().optional(),\n decisionId: z.string().min(1).optional(),\n})\n\nexport async function POST(request: NextRequest, { params }: { params: { id: string } }) {\n const auth = await getCustomerAuthFromRequest(request)\n if (!auth) {\n return NextResponse.json({ ok: false, error: 'Authentication required' }, { status: 401 })\n }\n\n const container = await createRequestContainer()\n const rbac = container.resolve('customerRbacService') as CustomerRbacService\n\n try {\n await requireCustomerFeature(auth, [PORTAL_TASKS_COMPLETE_FEATURE], rbac)\n } catch (response) {\n return response as NextResponse\n }\n\n try {\n const em = container.resolve('em') as import('@mikro-orm/postgresql').EntityManager\n const acl = await rbac.loadAcl(auth.sub, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n\n const resolved = await resolvePortalTaskPrincipal({ auth, em, isPortalAdmin: acl.isPortalAdmin })\n if (!resolved.ok) return NextResponse.json(resolved.body, { status: resolved.status })\n\n const parsed = completeSchema.safeParse(await request.json())\n if (!parsed.success) {\n return NextResponse.json(\n { ok: false, error: 'Invalid request body', details: parsed.error.format() },\n { status: 400 },\n )\n }\n\n const task = await em.findOne(UserTask, {\n id: params.id,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n if (!task) {\n return NextResponse.json(PORTAL_TASK_REFUSAL.body, { status: PORTAL_TASK_REFUSAL.status })\n }\n\n const decision = decidePortalTaskAccess(resolved.principal, task)\n if (!decision.actable) {\n return NextResponse.json(PORTAL_TASK_REFUSAL.body, { status: PORTAL_TASK_REFUSAL.status })\n }\n\n const taskHandler = container.resolve('taskHandler') as TaskHandlerService\n await taskHandler.completeUserTask(em, container, {\n taskId: params.id,\n formData: parsed.data.formData,\n // The portal principal's own id. `completeUserTask` compares it against\n // `claimedBy ?? assignedTo`, which for a portal task is the same id space,\n // so its own ownership check agrees with the predicate rather than\n // fighting it.\n userId: auth.sub,\n comments: parsed.data.comments,\n decisionId: parsed.data.decisionId,\n scope: { tenantId: auth.tenantId, organizationId: auth.orgId },\n })\n\n const updated = await em.findOne(UserTask, {\n id: params.id,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n\n return NextResponse.json({\n ok: true,\n task: updated ? serializeUserTask(updated) : null,\n })\n } catch (error) {\n const code = (error as { code?: unknown }).code\n if (code === 'UNKNOWN_DECISION' || code === 'FORM_VALIDATION_FAILED') {\n return NextResponse.json(\n { ok: false, error: error instanceof Error ? error.message : 'Invalid submission' },\n { status: 400 },\n )\n }\n // Anything the handler refuses on ownership or status grounds collapses into\n // the same 404 the predicate would have produced, so the act path discloses\n // no more than the read path.\n if (code === 'TASK_NOT_FOUND' || code === 'TASK_ASSIGNED_TO_ANOTHER_USER') {\n return NextResponse.json(PORTAL_TASK_REFUSAL.body, { status: PORTAL_TASK_REFUSAL.status })\n }\n logger.error('Error completing portal task', { err: error })\n return NextResponse.json({ ok: false, error: 'Failed to complete task' }, { status: 500 })\n }\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: workflowsTag,\n summary: 'Portal user task completion',\n methods: {\n POST: {\n summary: 'Complete a portal task',\n description:\n 'Completes a customer-assigned task and resumes the workflow. Only the assignee may complete; a portal admin who can read a company member\\'s task is refused.',\n requestBody: { schema: portalTaskCompleteRequestSchema },\n responses: [\n { status: 200, description: 'Task completed', schema: portalTaskCompleteResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid request body or form data', schema: portalTaskErrorSchema },\n { status: 401, description: 'Authentication required', schema: portalTaskErrorSchema },\n { status: 403, description: 'Insufficient permissions or no company association', schema: portalTaskErrorSchema },\n { status: 404, description: 'Task not found or not actionable by this principal', schema: portalTaskErrorSchema },\n { status: 500, description: 'Internal server error', schema: portalTaskErrorSchema },\n ],\n },\n },\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\n * Portal User Task completion API\n *\n * - POST /api/workflows/portal/tasks/[id]/complete\n *\n * `actable` is the gate, and on the portal branch `actable` is true only for the\n * assignee themselves: a portal admin who can READ a company member's task gets\n * `visible` without `actable` and is refused here. The refusal is a 404, same as\n * every other portal refusal \u2014 telling an admin \"you may see this but not\n * finish it\" would disclose more than the read already did.\n */\n\nimport { NextRequest, NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport {\n getCustomerAuthFromRequest,\n requireCustomerFeature,\n} from '@open-mercato/core/modules/customer_accounts/lib/customerAuth'\nimport type { CustomerRbacService } from '@open-mercato/core/modules/customer_accounts/services/customerRbacService'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { UserTask } from '../../../../../data/entities'\nimport { serializeUserTask } from '../../../../tasks/serialize'\nimport type { TaskHandlerService } from '../../../../../lib/task-handler'\nimport {\n decidePortalTaskAccess,\n PORTAL_TASK_REFUSAL,\n PORTAL_TASKS_COMPLETE_FEATURE,\n resolvePortalTaskPrincipal,\n} from '../../../../../lib/portal-task-access'\nimport {\n workflowsTag,\n portalTaskCompleteRequestSchema,\n portalTaskCompleteResponseSchema,\n portalTaskErrorSchema,\n} from '../../../../openapi'\n\nconst logger = createLogger('workflows')\n\nexport const metadata: { requireAuth?: boolean } = { requireAuth: false }\n\nconst completeSchema = z.object({\n formData: z.record(z.string(), z.any()),\n comments: z.string().optional(),\n decisionId: z.string().min(1).optional(),\n})\n\nexport async function POST(request: NextRequest, { params }: { params: { id: string } }) {\n const auth = await getCustomerAuthFromRequest(request)\n if (!auth) {\n return NextResponse.json({ ok: false, error: 'Authentication required' }, { status: 401 })\n }\n\n const container = await createRequestContainer()\n const rbac = container.resolve('customerRbacService') as CustomerRbacService\n\n try {\n await requireCustomerFeature(auth, [PORTAL_TASKS_COMPLETE_FEATURE], rbac)\n } catch (response) {\n return response as NextResponse\n }\n\n try {\n const em = container.resolve('em') as import('@mikro-orm/postgresql').EntityManager\n const acl = await rbac.loadAcl(auth.sub, {\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n\n const resolved = await resolvePortalTaskPrincipal({ auth, em, isPortalAdmin: acl.isPortalAdmin })\n if (!resolved.ok) return NextResponse.json(resolved.body, { status: resolved.status })\n\n const parsed = completeSchema.safeParse(await request.json())\n if (!parsed.success) {\n return NextResponse.json(\n { ok: false, error: 'Invalid request body', details: parsed.error.format() },\n { status: 400 },\n )\n }\n\n const task = await em.findOne(UserTask, {\n id: params.id,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n if (!task) {\n return NextResponse.json(PORTAL_TASK_REFUSAL.body, { status: PORTAL_TASK_REFUSAL.status })\n }\n\n const decision = decidePortalTaskAccess(resolved.principal, task)\n if (!decision.actable) {\n return NextResponse.json(PORTAL_TASK_REFUSAL.body, { status: PORTAL_TASK_REFUSAL.status })\n }\n\n const taskHandler = container.resolve('taskHandler') as TaskHandlerService\n await taskHandler.completeUserTask(em, container, {\n taskId: params.id,\n formData: parsed.data.formData,\n // The portal principal's own id. `completeUserTask` compares it against\n // `claimedBy ?? assignedTo`, which for a portal task is the same id space,\n // so its own ownership check agrees with the predicate rather than\n // fighting it.\n userId: auth.sub,\n comments: parsed.data.comments,\n decisionId: parsed.data.decisionId,\n scope: { tenantId: auth.tenantId, organizationId: auth.orgId },\n })\n\n const updated = await em.findOne(UserTask, {\n id: params.id,\n tenantId: auth.tenantId,\n organizationId: auth.orgId,\n })\n\n return NextResponse.json({\n ok: true,\n task: updated ? serializeUserTask(updated) : null,\n })\n } catch (error) {\n const code = (error as { code?: unknown }).code\n if (code === 'UNKNOWN_DECISION' || code === 'FORM_VALIDATION_FAILED') {\n return NextResponse.json(\n { ok: false, error: error instanceof Error ? error.message : 'Invalid submission' },\n { status: 400 },\n )\n }\n // Anything the handler refuses on ownership or status grounds collapses into\n // the same 404 the predicate would have produced, so the act path discloses\n // no more than the read path.\n if (\n code === 'TASK_NOT_FOUND' ||\n code === 'TASK_ASSIGNED_TO_ANOTHER_USER' ||\n code === 'WORKFLOW_NOT_ACTIVE'\n ) {\n return NextResponse.json(PORTAL_TASK_REFUSAL.body, { status: PORTAL_TASK_REFUSAL.status })\n }\n logger.error('Error completing portal task', { err: error })\n return NextResponse.json({ ok: false, error: 'Failed to complete task' }, { status: 500 })\n }\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: workflowsTag,\n summary: 'Portal user task completion',\n methods: {\n POST: {\n summary: 'Complete a portal task',\n description:\n 'Completes a customer-assigned task and resumes the workflow. Only the assignee may complete; a portal admin who can read a company member\\'s task is refused.',\n requestBody: { schema: portalTaskCompleteRequestSchema },\n responses: [\n { status: 200, description: 'Task completed', schema: portalTaskCompleteResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid request body or form data', schema: portalTaskErrorSchema },\n { status: 401, description: 'Authentication required', schema: portalTaskErrorSchema },\n { status: 403, description: 'Insufficient permissions or no company association', schema: portalTaskErrorSchema },\n { status: 404, description: 'Task not found or not actionable by this principal', schema: portalTaskErrorSchema },\n { status: 500, description: 'Internal server error', schema: portalTaskErrorSchema },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -105,6 +105,12 @@ async function POST(request, { params }) {
105
105
  { status: 409 }
106
106
  );
107
107
  }
108
+ if (error.code === "WORKFLOW_NOT_ACTIVE") {
109
+ return NextResponse.json(
110
+ { error: error.message, code: "WORKFLOW_NOT_ACTIVE" },
111
+ { status: 409 }
112
+ );
113
+ }
108
114
  if (error.message.includes("not found")) {
109
115
  return NextResponse.json(
110
116
  { error: error.message },
@@ -152,7 +158,7 @@ const openApi = {
152
158
  { status: 401, description: "Unauthorized", schema: workflowErrorSchema },
153
159
  { status: 403, description: "The task is visible to the caller but is not theirs to act on \u2014 it has no assignee and no role queue, so it must be reassigned first (\xA76.4: administration widens seeing, never acting).", schema: workflowErrorSchema },
154
160
  { status: 404, description: "Task not found, or not visible to the caller", schema: workflowErrorSchema },
155
- { status: 409, description: "Task already completed", schema: workflowErrorSchema },
161
+ { status: 409, description: "Task already completed, assigned to another user, or its workflow run is no longer active (`code: WORKFLOW_NOT_ACTIVE` \u2014 the run is completed, failed, cancelled, compensating or compensated)", schema: workflowErrorSchema },
156
162
  { status: 500, description: "Internal server error", schema: workflowErrorSchema }
157
163
  ]
158
164
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../../src/modules/workflows/api/tasks/%5Bid%5D/complete/route.ts"],
4
- "sourcesContent": ["/**\n * User Task Completion API\n *\n * Endpoints:\n * - POST /api/workflows/tasks/[id]/complete - Complete a user task with form data\n */\n\nimport { NextRequest, NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveSingleOrganizationIdOrDeny } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'\nimport type { TaskHandlerService } from '../../../../lib/task-handler'\nimport { gateTaskAction } from '../../../../lib/task-visibility-request'\nimport {\n workflowsTag,\n completeTaskRequestSchema as openApiCompleteTaskSchema,\n userTaskCompleteResponseSchema,\n workflowErrorSchema,\n} from '../../../openapi'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\n\nconst logger = createLogger('workflows')\n\nexport const metadata = {\n requireAuth: true,\n requireFeatures: ['workflows.tasks.complete'],\n}\n\n// Request body schema\nconst completeTaskSchema = z.object({\n formData: z.record(z.string(), z.any()),\n comments: z.string().optional(),\n /**\n * The decision button the assignee pressed. Optional and additive: a task\n * without decisions completes through the plain form exactly as before.\n */\n decisionId: z.string().min(1).optional(),\n})\n\n/**\n * POST /api/workflows/tasks/[id]/complete\n *\n * Complete a user task with form data\n *\n * Request body:\n * {\n * formData: { [key: string]: any }, // Form field values\n * comments: string (optional) // Optional comments\n * }\n *\n * This endpoint:\n * 1. Validates the task can be completed\n * 2. Validates form data against the task's form schema\n * 3. Updates the task with completion data\n * 4. Merges form data into workflow context\n * 5. Resumes workflow execution\n */\nexport async function POST(\n request: NextRequest,\n { params }: { params: { id: string } }\n) {\n try {\n const container = await createRequestContainer()\n const em = container.resolve('em')\n const auth = await getAuthFromRequest(request)\n\n if (!auth) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request })\n const tenantId = auth.tenantId\n const organizationId = resolveSingleOrganizationIdOrDeny(scope, auth)\n\n if (!tenantId || !organizationId) {\n return NextResponse.json(\n { error: 'Missing tenant or organization context' },\n { status: 400 }\n )\n }\n\n // Parse request body\n const body = await request.json()\n const parseResult = completeTaskSchema.safeParse(body)\n\n if (!parseResult.success) {\n return NextResponse.json(\n {\n error: 'Invalid request body',\n details: parseResult.error.format(),\n },\n { status: 400 }\n )\n }\n\n const { formData, comments, decisionId } = parseResult.data\n\n // \u00A76.4 replaces the bare tenant/organization check that used to stand here.\n // The narrowing D-1 keeps `workflows.tasks.complete` on the route and still\n // demands: holding it no longer completes anyone ELSE's task. Refusals are\n // indistinguishable from a nonexistent id unless the caller already sees the\n // row.\n const gate = await gateTaskAction({\n container,\n em,\n auth: { userId: auth.sub, tenantId, roleNames: auth.roles ?? [] },\n taskId: params.id,\n organizationId,\n // Complete is the one act where the gate, not the handler, decides\n // ownership: the handler leaves an OWNERLESS row open to anyone holding\n // the feature, which is exactly the pre-change semantics \u00A76.4 narrows.\n requireOwnership: true,\n })\n\n if (!gate.allowed) {\n return NextResponse.json(gate.refusal.body, { status: gate.refusal.status })\n }\n\n const { UserTask } = await import('../../../../data/entities')\n\n // Call task handler to complete task and resume workflow\n const taskHandler = container.resolve<TaskHandlerService>('taskHandler')\n await taskHandler.completeUserTask(em, container, {\n taskId: params.id,\n formData,\n userId: auth.sub,\n comments,\n decisionId,\n scope: { tenantId, organizationId },\n })\n\n // Fetch updated task\n const updatedTask = await em.findOne(UserTask, {\n id: params.id,\n tenantId,\n organizationId,\n })\n\n return NextResponse.json({\n data: updatedTask,\n message: 'Task completed successfully. Workflow resumed.',\n })\n } catch (error) {\n if (isCrudHttpError(error)) return NextResponse.json(error.body, { status: error.status })\n logger.error('Error completing user task', { err: error })\n\n // Handle specific error codes from task-handler\n if (error instanceof Error) {\n if ((error as { code?: unknown }).code === 'UNKNOWN_DECISION') {\n return NextResponse.json(\n { error: error.message },\n { status: 400 }\n )\n }\n // \"Task is assigned to another user\" matches none of the message arms\n // below, so this refusal used to answer 500. It is a conflict.\n if ((error as { code?: unknown }).code === 'TASK_ASSIGNED_TO_ANOTHER_USER') {\n return NextResponse.json(\n { error: error.message },\n { status: 409 }\n )\n }\n if (error.message.includes('not found')) {\n return NextResponse.json(\n { error: error.message },\n { status: 404 }\n )\n }\n if (error.message.includes('validation')) {\n return NextResponse.json(\n { error: error.message },\n { status: 400 }\n )\n }\n if (error.message.includes('already completed')) {\n return NextResponse.json(\n { error: error.message },\n { status: 409 }\n )\n }\n }\n\n return NextResponse.json(\n {\n error: 'Failed to complete user task',\n details: error instanceof Error ? error.message : String(error),\n },\n { status: 500 }\n )\n }\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: workflowsTag,\n summary: 'Complete user task',\n methods: {\n POST: {\n summary: 'Complete a task with form data',\n description: 'Validates form data against task schema, updates task with completion data, merges form data into workflow context, and resumes workflow execution.',\n requestBody: {\n contentType: 'application/json',\n schema: openApiCompleteTaskSchema,\n },\n responses: [\n { status: 200, description: 'Task completed successfully', schema: userTaskCompleteResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid request body, validation failed, or missing context', schema: workflowErrorSchema },\n { status: 401, description: 'Unauthorized', schema: workflowErrorSchema },\n { status: 403, description: 'The task is visible to the caller but is not theirs to act on \u2014 it has no assignee and no role queue, so it must be reassigned first (\u00A76.4: administration widens seeing, never acting).', schema: workflowErrorSchema },\n { status: 404, description: 'Task not found, or not visible to the caller', schema: workflowErrorSchema },\n { status: 409, description: 'Task already completed', schema: workflowErrorSchema },\n { status: 500, description: 'Internal server error', schema: workflowErrorSchema },\n ],\n },\n },\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\n * User Task Completion API\n *\n * Endpoints:\n * - POST /api/workflows/tasks/[id]/complete - Complete a user task with form data\n */\n\nimport { NextRequest, NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveSingleOrganizationIdOrDeny } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'\nimport type { TaskHandlerService } from '../../../../lib/task-handler'\nimport { gateTaskAction } from '../../../../lib/task-visibility-request'\nimport {\n workflowsTag,\n completeTaskRequestSchema as openApiCompleteTaskSchema,\n userTaskCompleteResponseSchema,\n workflowErrorSchema,\n} from '../../../openapi'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\n\nconst logger = createLogger('workflows')\n\nexport const metadata = {\n requireAuth: true,\n requireFeatures: ['workflows.tasks.complete'],\n}\n\n// Request body schema\nconst completeTaskSchema = z.object({\n formData: z.record(z.string(), z.any()),\n comments: z.string().optional(),\n /**\n * The decision button the assignee pressed. Optional and additive: a task\n * without decisions completes through the plain form exactly as before.\n */\n decisionId: z.string().min(1).optional(),\n})\n\n/**\n * POST /api/workflows/tasks/[id]/complete\n *\n * Complete a user task with form data\n *\n * Request body:\n * {\n * formData: { [key: string]: any }, // Form field values\n * comments: string (optional) // Optional comments\n * }\n *\n * This endpoint:\n * 1. Validates the task can be completed\n * 2. Validates form data against the task's form schema\n * 3. Updates the task with completion data\n * 4. Merges form data into workflow context\n * 5. Resumes workflow execution\n */\nexport async function POST(\n request: NextRequest,\n { params }: { params: { id: string } }\n) {\n try {\n const container = await createRequestContainer()\n const em = container.resolve('em')\n const auth = await getAuthFromRequest(request)\n\n if (!auth) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request })\n const tenantId = auth.tenantId\n const organizationId = resolveSingleOrganizationIdOrDeny(scope, auth)\n\n if (!tenantId || !organizationId) {\n return NextResponse.json(\n { error: 'Missing tenant or organization context' },\n { status: 400 }\n )\n }\n\n // Parse request body\n const body = await request.json()\n const parseResult = completeTaskSchema.safeParse(body)\n\n if (!parseResult.success) {\n return NextResponse.json(\n {\n error: 'Invalid request body',\n details: parseResult.error.format(),\n },\n { status: 400 }\n )\n }\n\n const { formData, comments, decisionId } = parseResult.data\n\n // \u00A76.4 replaces the bare tenant/organization check that used to stand here.\n // The narrowing D-1 keeps `workflows.tasks.complete` on the route and still\n // demands: holding it no longer completes anyone ELSE's task. Refusals are\n // indistinguishable from a nonexistent id unless the caller already sees the\n // row.\n const gate = await gateTaskAction({\n container,\n em,\n auth: { userId: auth.sub, tenantId, roleNames: auth.roles ?? [] },\n taskId: params.id,\n organizationId,\n // Complete is the one act where the gate, not the handler, decides\n // ownership: the handler leaves an OWNERLESS row open to anyone holding\n // the feature, which is exactly the pre-change semantics \u00A76.4 narrows.\n requireOwnership: true,\n })\n\n if (!gate.allowed) {\n return NextResponse.json(gate.refusal.body, { status: gate.refusal.status })\n }\n\n const { UserTask } = await import('../../../../data/entities')\n\n // Call task handler to complete task and resume workflow\n const taskHandler = container.resolve<TaskHandlerService>('taskHandler')\n await taskHandler.completeUserTask(em, container, {\n taskId: params.id,\n formData,\n userId: auth.sub,\n comments,\n decisionId,\n scope: { tenantId, organizationId },\n })\n\n // Fetch updated task\n const updatedTask = await em.findOne(UserTask, {\n id: params.id,\n tenantId,\n organizationId,\n })\n\n return NextResponse.json({\n data: updatedTask,\n message: 'Task completed successfully. Workflow resumed.',\n })\n } catch (error) {\n if (isCrudHttpError(error)) return NextResponse.json(error.body, { status: error.status })\n logger.error('Error completing user task', { err: error })\n\n // Handle specific error codes from task-handler\n if (error instanceof Error) {\n if ((error as { code?: unknown }).code === 'UNKNOWN_DECISION') {\n return NextResponse.json(\n { error: error.message },\n { status: 400 }\n )\n }\n // \"Task is assigned to another user\" matches none of the message arms\n // below, so this refusal used to answer 500. It is a conflict.\n if ((error as { code?: unknown }).code === 'TASK_ASSIGNED_TO_ANOTHER_USER') {\n return NextResponse.json(\n { error: error.message },\n { status: 409 }\n )\n }\n if ((error as { code?: unknown }).code === 'WORKFLOW_NOT_ACTIVE') {\n return NextResponse.json(\n { error: error.message, code: 'WORKFLOW_NOT_ACTIVE' },\n { status: 409 }\n )\n }\n if (error.message.includes('not found')) {\n return NextResponse.json(\n { error: error.message },\n { status: 404 }\n )\n }\n if (error.message.includes('validation')) {\n return NextResponse.json(\n { error: error.message },\n { status: 400 }\n )\n }\n if (error.message.includes('already completed')) {\n return NextResponse.json(\n { error: error.message },\n { status: 409 }\n )\n }\n }\n\n return NextResponse.json(\n {\n error: 'Failed to complete user task',\n details: error instanceof Error ? error.message : String(error),\n },\n { status: 500 }\n )\n }\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: workflowsTag,\n summary: 'Complete user task',\n methods: {\n POST: {\n summary: 'Complete a task with form data',\n description: 'Validates form data against task schema, updates task with completion data, merges form data into workflow context, and resumes workflow execution.',\n requestBody: {\n contentType: 'application/json',\n schema: openApiCompleteTaskSchema,\n },\n responses: [\n { status: 200, description: 'Task completed successfully', schema: userTaskCompleteResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid request body, validation failed, or missing context', schema: workflowErrorSchema },\n { status: 401, description: 'Unauthorized', schema: workflowErrorSchema },\n { status: 403, description: 'The task is visible to the caller but is not theirs to act on \u2014 it has no assignee and no role queue, so it must be reassigned first (\u00A76.4: administration widens seeing, never acting).', schema: workflowErrorSchema },\n { status: 404, description: 'Task not found, or not visible to the caller', schema: workflowErrorSchema },\n { status: 409, description: 'Task already completed, assigned to another user, or its workflow run is no longer active (`code: WORKFLOW_NOT_ACTIVE` \u2014 the run is completed, failed, cancelled, compensating or compensated)', schema: workflowErrorSchema },\n { status: 500, description: 'Internal server error', schema: workflowErrorSchema },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -156,7 +156,8 @@ function UserTaskDetailPage({ params }) {
156
156
  refreshTask();
157
157
  } else {
158
158
  const failure = result.result;
159
- flash(failure?.error || t("workflows.tasks.messages.completeFailed"), "error");
159
+ const failureMessage = failure?.code === "WORKFLOW_NOT_ACTIVE" ? t("workflows.tasks.messages.workflowNotActive") : failure?.error || t("workflows.tasks.messages.completeFailed");
160
+ flash(failureMessage, "error");
160
161
  }
161
162
  } catch (err) {
162
163
  logger.error("Error completing task", { err });