@camunda/ccma-shared-types 10.8.0 → 10.9.0

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 (54) hide show
  1. package/dist/_tests/approvals/approval-rules.spec.d.ts +1 -0
  2. package/dist/_tests/approvals/llm-budget-top-up.spec.d.ts +1 -0
  3. package/dist/_tests/types/SalesPlanType.type.spec.d.ts +1 -0
  4. package/dist/agent-tokens/agent-tokens.d.ts +62 -3
  5. package/dist/agent-tokens/agent-tokens.js +8 -0
  6. package/dist/agent-tokens/agent-tokens.js.map +2 -2
  7. package/dist/approvals/approval-rules.d.ts +172 -0
  8. package/dist/approvals/approval-rules.js +71 -0
  9. package/dist/approvals/approval-rules.js.map +7 -0
  10. package/dist/approvals/cluster-bulk-operation.d.ts +5 -20
  11. package/dist/approvals/cluster-bulk-operation.js +3 -5
  12. package/dist/approvals/cluster-bulk-operation.js.map +2 -2
  13. package/dist/approvals/delete-cluster-corpses.d.ts +0 -15
  14. package/dist/approvals/delete-cluster-corpses.js +1 -7
  15. package/dist/approvals/delete-cluster-corpses.js.map +2 -2
  16. package/dist/approvals/edit-generation.d.ts +0 -8
  17. package/dist/approvals/edit-generation.js +0 -2
  18. package/dist/approvals/edit-generation.js.map +2 -2
  19. package/dist/approvals/force-delete-cluster-plan-type.d.ts +0 -18
  20. package/dist/approvals/force-delete-cluster-plan-type.js +1 -7
  21. package/dist/approvals/force-delete-cluster-plan-type.js.map +2 -2
  22. package/dist/approvals/policy.d.ts +94 -7
  23. package/dist/approvals/policy.js +125 -6
  24. package/dist/approvals/policy.js.map +2 -2
  25. package/dist/approvals/use-cases.const.d.ts +36 -6
  26. package/dist/approvals/use-cases.const.js +48 -16
  27. package/dist/approvals/use-cases.const.js.map +2 -2
  28. package/dist/constants/direct-route-lock.const.d.ts +13 -2
  29. package/dist/constants/direct-route-lock.const.js +2 -0
  30. package/dist/constants/direct-route-lock.const.js.map +2 -2
  31. package/dist/constants/misc.const.d.ts +38 -42
  32. package/dist/constants/misc.const.js +27 -28
  33. package/dist/constants/misc.const.js.map +2 -2
  34. package/dist/dtos/ApprovalJob.dto.d.ts +69 -5
  35. package/dist/dtos/ApprovalJob.dto.js +2 -0
  36. package/dist/dtos/ApprovalJob.dto.js.map +2 -2
  37. package/dist/dtos/ApprovalSummaryNames.dto.d.ts +37 -0
  38. package/dist/dtos/ApprovalSummaryNames.dto.js +1 -0
  39. package/dist/dtos/ApprovalSummaryNames.dto.js.map +7 -0
  40. package/dist/index.d.ts +15 -12
  41. package/dist/index.js +47 -27
  42. package/dist/index.js.map +2 -2
  43. package/dist/permissions/combo.js +3 -0
  44. package/dist/permissions/combo.js.map +2 -2
  45. package/dist/permissions/console.permissions.d.ts +17 -0
  46. package/dist/permissions/console.permissions.js +15 -1
  47. package/dist/permissions/console.permissions.js.map +2 -2
  48. package/dist/types/SalesPlanType.type.d.ts +5 -0
  49. package/dist/types/SalesPlanType.type.js +7 -0
  50. package/dist/types/SalesPlanType.type.js.map +2 -2
  51. package/dist/utils/user.utils.js +1 -1
  52. package/dist/utils/user.utils.js.map +2 -2
  53. package/dist/utils/user.utils.spec.d.ts +1 -0
  54. package/package.json +1 -1
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/approvals/policy.ts"],
4
- "sourcesContent": ["/*\n * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH\n * under one or more contributor license agreements. Licensed under a commercial license.\n * You may not use this file except in compliance with the commercial license.\n */\n\nimport { UNKNOWN_ACTOR_EMAIL } from '../constants/misc.const.js'\nimport type {\n ApprovalJobDto,\n ApprovalUseCaseDto,\n} from '../dtos/ApprovalJob.dto.js'\nimport { ConsolePermissions } from '../permissions/console.permissions.js'\n\n/**\n * The rules of the approval mechanism, as pure functions.\n *\n * Three consumers run this file: control-backend decides whether to reject a\n * request, control-frontend decides whether to disable a button, and cloudbot\n * decides whether to answer. One copy means the button and the endpoint can\n * never disagree.\n *\n * Two choices in the signatures carry the design:\n *\n * - The actor is an interface with a `has` method. The backend holds an array\n * of Auth0 claims, and `useSession()` in the frontend gives a flat record of\n * booleans. Neither side has to convert to the shape of the other.\n * - `now` is a parameter. The expiry is lazy, so a stored `pending` request can\n * be expired already. A parameter makes a difference between the two clocks\n * an input that a test can set, and not a silent disagreement.\n *\n * No function here does I/O or returns a promise. The browser call site is what\n * enforces that, and it also stops a later use case from turning a list of 50\n * rows into 200 network calls.\n */\n\n/** Who is asking. See the note above on why this is an interface. */\nexport interface ApprovalActorView {\n userId: string\n /**\n * The email of the actor. It is the SECOND identity of the four-eyes rule.\n *\n * `userId` alone is not enough. One person can hold more than one identity in\n * Auth0 \u2014 a connection through SSO and a connection through a database, or an\n * account that was made again \u2014 and each one carries a different `sub`. The\n * same human then opens a request with one identity and approves it with the\n * other, which is the rule defeated.\n *\n * It is optional because a token can carry no email. See\n * `isSameActorEmail` for what that means for the comparison.\n */\n email?: string\n has(permission: string): boolean\n}\n\n/**\n * Builds the `has` of an actor from the permissions that the actor holds.\n *\n * `ConsolePermissions.APPROVALS_ALL_USE_CASES` stands in for every permission\n * that a use case asks for. Both actor builders call this function, the one of\n * control-backend and the one of the browser, so the button and the route\n * agree on it. A rule that reads `actor.has` needs no change for it, and nor\n * does a use case that a later change adds.\n */\nexport function approvalPermissionCheck(\n held: ReadonlySet<string>,\n): (permission: string) => boolean {\n const all = held.has(ConsolePermissions.APPROVALS_ALL_USE_CASES)\n return (permission) => all || held.has(permission)\n}\n\n/**\n * Reports whether two email addresses name one person.\n *\n * Two values that are absent, empty, or `UNKNOWN_ACTOR_EMAIL` are NOT one\n * person. A token with no email would otherwise make every actor equal to\n * every other actor, and the rule would refuse everybody.\n *\n * The comparison removes the spaces and the case. Auth0 gives the address as\n * the identity provider wrote it, so `A.B@camunda.com` and `a.b@camunda.com`\n * are one mailbox and must not pass as two people.\n */\nexport function isSameActorEmail(one?: string, other?: string): boolean {\n const left = comparableActorEmail(one)\n return left.length > 0 && left === comparableActorEmail(other)\n}\n\n/**\n * The address in the one form that a comparison may use.\n *\n * An absent address, an empty one and `UNKNOWN_ACTOR_EMAIL` all become an\n * empty string, because none of the three names a person. The spaces and the\n * case go as well: Auth0 gives the address as the identity provider wrote it.\n */\nfunction comparableActorEmail(value?: string): string {\n const trimmed = value?.trim().toLowerCase() ?? ''\n return trimmed === UNKNOWN_ACTOR_EMAIL ? '' : trimmed\n}\n\n/**\n * Reports whether one identity of a request names the actor.\n *\n * The `sub` of the token OR the email, because one person can hold more than\n * one Auth0 identity. Every rule that asks \"is this the same human?\" reads both\n * through this function, so a rule cannot forget the second half: `canWithdraw`\n * did, and it refused a creator whose browser and whose service resolved the\n * id from two different tokens.\n */\nexport function isSameActor(\n identity: { userId: string; email?: string },\n actor: ApprovalActorView,\n): boolean {\n return (\n identity.userId === actor.userId ||\n isSameActorEmail(identity.email, actor.email)\n )\n}\n\n/**\n * Reports whether this actor carries an address that names a person.\n *\n * A machine-to-machine token carries none: accounts-backend builds the\n * permissions of such a token from its `permissions` claim alone, and there is\n * no person behind it. See `creatorEmailRequired` of the use case.\n */\nexport function hasActorEmail(actor: ApprovalActorView): boolean {\n return comparableActorEmail(actor.email).length > 0\n}\n\n/**\n * Why an action is not allowed.\n *\n * The code is for a program: control-backend maps it to a status, and the\n * frontend can branch on it. The message is for a person, and it is shown as\n * written.\n */\nexport type ApprovalDenialCode =\n | 'forbidden'\n | 'not-pending'\n | 'self-approval'\n | 'self-rejection'\n | 'already-acknowledged'\n | 'expired'\n | 'not-creator'\n | 'not-executable'\n | 'no-reason'\n | 'too-few-acks'\n | 'no-creator-email'\n\nexport type ApprovalDecision =\n | { allowed: true }\n | { allowed: false; code: ApprovalDenialCode; message: string }\n\nconst ALLOWED: ApprovalDecision = { allowed: true }\n\nfunction deny(code: ApprovalDenialCode, message: string): ApprovalDecision {\n return { allowed: false, code, message }\n}\n\n/**\n * Reports whether the time to answer a request has passed.\n *\n * Only a `pending` request expires. A request that already reached an end state\n * keeps that state, because \"expired\" would lose the answer that was given.\n */\nexport function isExpired(job: ApprovalJobDto, now: Date): boolean {\n return (\n job.status === 'pending' &&\n new Date(job.expiresAt).getTime() <= now.getTime()\n )\n}\n\n/**\n * Reports the status to show. It is `expired` once the time to answer has\n * passed, or once the run window of an `approved` or `failed` request has\n * ended. control-backend writes both, and this shows them before the write.\n */\nexport function effectiveStatus(\n job: ApprovalJobDto,\n now: Date,\n): ApprovalJobDto['status'] {\n return isExpired(job, now) || isRunWindowClosed(job, now)\n ? 'expired'\n : job.status\n}\n\n/** Reports how many more acknowledgements the request needs. */\nexport function remainingAcks(job: ApprovalJobDto): number {\n return Math.max(0, job.requiredAcks - job.acks.length)\n}\n\n/**\n * Reports whether the actor holds EVERY permission of the set.\n *\n * Every, and not one of them. A payload that raises the guard adds a\n * permission, so \"one of them\" would let the base permission alone carry a\n * request that the extra permission exists to stop.\n *\n * An EMPTY set is held by nobody. That is the opposite of what `Array.every`\n * answers, and it is deliberate: this function guards a privileged action, so\n * the empty case must fail closed. A row with no permissions is not a row that\n * everybody may run \u2014 it is a row that lost its guard, through a migration\n * that did not backfill it or a write that went around the service. Every use\n * case builds a set of at least one, so no real request meets this branch.\n */\nexport function hasEveryPermission(\n actor: ApprovalActorView,\n permissions: string[],\n): boolean {\n if (permissions.length === 0) {\n return false\n }\n return permissions.every((permission) => actor.has(permission))\n}\n\n/**\n * What a person must hold to act on a request.\n *\n * A request carries its own set, because a payload can raise the guard. A use\n * case that never does has a set of one, and it is the base permission.\n */\nexport function requiredPermissionsOf(job: ApprovalJobDto): string[] {\n return job.requiredPermissions\n}\n\n/**\n * Who can READ a request.\n *\n * Two people: one who can act on it, and the one who opened it. A Camundi who\n * holds neither has no reason to read which customer somebody proposed to\n * expire, so the list and the detail both hide it.\n *\n * The rule reads the BASE permission of the use case and not the effective set\n * of the request. A payload that raises the guard raises who can APPROVE, and\n * not who can look: a generation update with the yolo flag is visible to\n * everybody who can approve a plain one, and only the yolo permission runs it.\n * The list filters by use case in SQL, which is what makes one page of rows\n * the same page that a person is allowed to read.\n */\nexport function canView(\n job: ApprovalJobDto,\n actor: ApprovalActorView,\n useCase: ApprovalUseCaseDto,\n): ApprovalDecision {\n if (\n actor.has(useCase.requiredPermission) ||\n isSameActor(job.createdBy, actor)\n ) {\n return ALLOWED\n }\n return deny('forbidden', 'You cannot see this request.')\n}\n\n/**\n * Who can OPEN a request, and with what.\n *\n * `createPermission` is what keeps the pool that asks as small as the pool that\n * approves. A use case that leaves it absent says the opposite on purpose: the\n * approvals are the guard, and anybody signed in can propose the action.\n *\n * The two checks after it are about the request and not about the person. A\n * count below the floor of the use case, and a missing reason, are both a 400\n * and not a 403, so the words say which one the caller must repair.\n */\nexport function canCreate(\n actor: ApprovalActorView,\n useCase: ApprovalUseCaseDto,\n request: {\n reason?: string\n requiredAcks?: number\n /**\n * The effective permissions of the payload, from `permissionsFor`.\n *\n * Absent means \"the base permission alone\", which is what most use cases\n * build. Only the permissions ABOVE the base one gate opening a request:\n * see the note in the body.\n */\n requiredPermissions?: string[]\n } = {},\n): ApprovalDecision {\n // The message NAMES the permission. \"You cannot open this request\" is true\n // and useless: a Camundi who reads it cannot tell a missing permission from\n // a rule they never heard of, and the detail page names the permissions of\n // a request in the same way.\n if (useCase.createPermission && !actor.has(useCase.createPermission)) {\n return deny(\n 'forbidden',\n `Opening this request needs ${useCase.createPermission}, and you do not hold it.`,\n )\n }\n // What the PAYLOAD raised, on top of the base permission of the use case.\n // A bulk generation update with the yolo flag needs the yolo permission, and\n // without this check a person who holds the base permission alone could open\n // one. They could not approve it and could not run it, so it is not an\n // escalation \u2014 it is a request that its own author is not allowed to make,\n // waiting in the queue of the people who are.\n //\n // The BASE permission is deliberately not checked here. `createPermission`\n // above is the only gate on the person, and a use case that leaves it absent\n // means that anybody signed in can propose the action. Checking the base\n // permission too would take that choice away from every use case.\n const raised = (request.requiredPermissions ?? []).filter(\n (permission) => permission !== useCase.requiredPermission,\n )\n if (raised.length > 0 && !hasEveryPermission(actor, raised)) {\n return deny(\n 'forbidden',\n `This payload needs ${raised.join(' and ')}, and you do not hold it.`,\n )\n }\n // A request that needs no approval RUNS when it is opened, so the person who\n // opens it must hold what running it needs. Every other use case leaves this\n // to the approvals, which is why the base permission is not checked above.\n // With a floor of zero there is no approval to leave it to.\n const toRun = request.requiredPermissions ?? [useCase.requiredPermission]\n // A service that opens a request of its own use case holds the\n // `createPermission` checked above, and that is the guard of the run. Without\n // a `createPermission` there is no such guard, so the flag lifts nothing.\n const openedBySystem = useCase.openedBySystem && !!useCase.createPermission\n if (\n resolveRequiredAcks(useCase, request.requiredAcks) === 0 &&\n !useCase.openToEveryone &&\n !openedBySystem &&\n !hasEveryPermission(actor, toRun)\n ) {\n // The message NAMES the permission, and it says why opening needs it. A\n // Camundi who reads \"you cannot open this request\" for an action that\n // needs no approval cannot tell which of the two rules refused them.\n return deny(\n 'forbidden',\n `This request needs no approval, so opening it runs it. That needs ` +\n `${toRun.join(' and ')}, and you do not hold it.`,\n )\n }\n // The action of this use case carries the address of the person who asked,\n // so a caller with no address must be refused HERE. The alternative is a\n // request that looks ordinary, collects its approvals over days, and then\n // fails because the other service cannot find a user called \"unknown\".\n if (useCase.creatorEmailRequired && !hasActorEmail(actor)) {\n return deny(\n 'no-creator-email',\n 'This action needs the email address of the person who asks for it, ' +\n 'and your session carries none. Sign in as a person to open this ' +\n 'request.',\n )\n }\n if (useCase.reasonRequired && (request.reason ?? '').trim().length === 0) {\n return deny('no-reason', 'Give a reason to open this request.')\n }\n if (\n request.requiredAcks !== undefined &&\n request.requiredAcks < useCase.minAcks\n ) {\n return deny(\n 'too-few-acks',\n `This action needs at least ${useCase.minAcks} approvals.`,\n )\n }\n return ALLOWED\n}\n\n/**\n * The approvals that a new request asks for.\n *\n * The caller names a number or names none. A number below the floor is refused\n * by `canCreate`, and this function holds the answer inside the range anyway:\n * the two run in the same call, and a floor that only one of them knows is a\n * floor that a later caller can step over.\n */\nexport function resolveRequiredAcks(\n useCase: ApprovalUseCaseDto,\n requested?: number,\n): number {\n if (requested === undefined) {\n return Math.max(useCase.defaultAcks, useCase.minAcks)\n }\n return Math.max(Math.trunc(requested), useCase.minAcks)\n}\n\n/**\n * The four-eyes rule.\n *\n * The bans are in the order a person meets them, and each one has its own\n * message: \"you already approved this\" and \"you cannot approve your own\n * request\" are different mistakes, and one message for both helps nobody.\n *\n * Each of the two bans on a person reads BOTH identities: the `sub` of the\n * token, and the email. See `ApprovalActorView.email` for why one of them is\n * not enough.\n */\nexport function canAcknowledge(\n job: ApprovalJobDto,\n actor: ApprovalActorView,\n now: Date,\n): ApprovalDecision {\n if (!hasEveryPermission(actor, job.requiredPermissions)) {\n return deny('forbidden', 'You cannot approve this request.')\n }\n if (isExpired(job, now)) {\n return deny(\n 'expired',\n 'This request expired and can no longer be approved.',\n )\n }\n if (job.status !== 'pending') {\n return deny(\n 'not-pending',\n `This request is ${job.status} and can no longer be approved.`,\n )\n }\n if (isSameActor(job.createdBy, actor)) {\n return deny(\n 'self-approval',\n 'You opened this request, so you cannot approve it.',\n )\n }\n if (job.acks.some((ack) => isSameActor(ack, actor))) {\n return deny('already-acknowledged', 'You already approved this request.')\n }\n return ALLOWED\n}\n\n/**\n * A rejection needs the same permission as an acknowledgement, and a reason.\n *\n * Two people answer a request, and each one has ONE verb. Somebody else\n * approves or rejects it; the person who opened it withdraws it. The creator is\n * refused here for the same reason `canAcknowledge` refuses them: one person\n * must not be both sides of a four-eyes decision. The message names the button\n * that they do have, and the trail loses nothing, because a withdrawal writes\n * its reason into the same field.\n *\n * The ORDER of the checks is the order that a person meets them, and the state\n * of the request comes before the reason. A closed request must answer\n * \"already closed\" whatever the caller typed: with the reason first, a page\n * that had no reason yet read `no-reason` from a rejected request, and the\n * Reject button of control-frontend stayed enabled on a request that nothing\n * could reject any more.\n */\nexport function canReject(\n job: ApprovalJobDto,\n actor: ApprovalActorView,\n now: Date,\n reason: string,\n): ApprovalDecision {\n if (!hasEveryPermission(actor, job.requiredPermissions)) {\n return deny('forbidden', 'You cannot reject this request.')\n }\n if (isExpired(job, now)) {\n return deny(\n 'expired',\n 'This request expired and can no longer be rejected.',\n )\n }\n if (job.status !== 'pending') {\n return deny(\n 'not-pending',\n `This request is ${job.status} and can no longer be rejected.`,\n )\n }\n if (isSameActor(job.createdBy, actor)) {\n return deny(\n 'self-rejection',\n 'You opened this request, so withdraw it instead of rejecting it.',\n )\n }\n if (reason.trim().length === 0) {\n return deny('no-reason', 'Give a reason to reject this request.')\n }\n return ALLOWED\n}\n\n/**\n * Only the person who opened a request can take it back.\n *\n * BOTH identities, as every other rule of this file reads them. One person can\n * hold two Auth0 identities with two different `sub` values, and a rule that\n * read the id alone refused the creator their own withdrawal as soon as the\n * browser and the service resolved that id from two different tokens.\n */\nexport function canWithdraw(\n job: ApprovalJobDto,\n actor: ApprovalActorView,\n now: Date,\n): ApprovalDecision {\n if (!isSameActor(job.createdBy, actor)) {\n return deny(\n 'not-creator',\n 'Only the person who opened a request can withdraw it.',\n )\n }\n if (isExpired(job, now)) {\n return deny(\n 'expired',\n 'This request expired and can no longer be withdrawn.',\n )\n }\n if (job.status !== 'pending') {\n return deny(\n 'not-pending',\n `This request is ${job.status} and can no longer be withdrawn.`,\n )\n }\n return ALLOWED\n}\n\n/**\n * Runs a request that has its approvals.\n *\n * This is the button of the explicit mode. `failed` is executable again,\n * because a failure is often a problem of the system that is now repaired, and\n * asking for the approvals a second time helps nobody. Both run only inside\n * the run window, see `EXECUTE_WINDOW_DAYS`.\n *\n * ANY holder of the permission may press it, and not only the person who\n * opened the request. The decision was made when the request reached\n * `approved`: `canAcknowledge` refuses the creator, so the approvals on the\n * row came from other people, and the button only carries out what they\n * already agreed to. Holding the button to the creator would let one Camundi\n * on holiday freeze an approved action, and a use case that must not run\n * without its author has `executionMode: 'auto'` instead.\n *\n * Who PRESSED it and who ASKED for it are two different facts, and both are\n * kept: the audit trail of control-backend records the actor of the run, and\n * an action that names a person to another service names the creator. See\n * `onBehalfOf` of `extend-trial`.\n */\nexport function canExecute(\n job: ApprovalJobDto,\n actor: ApprovalActorView,\n // A default, because the package is published and a caller from before\n // the run window passes two arguments.\n now: Date = new Date(),\n): ApprovalDecision {\n if (!hasEveryPermission(actor, job.requiredPermissions)) {\n return deny('forbidden', 'You cannot run this request.')\n }\n // control-backend writes `expired` before it answers a read, so the row\n // that a person sees is usually already `expired` and not past its window.\n if (job.status === 'expired') {\n return deny('expired', 'This request is expired. Open a new request.')\n }\n if (job.status !== 'approved' && job.status !== 'failed') {\n return deny(\n 'not-executable',\n `This request is ${job.status} and cannot be run.`,\n )\n }\n if (isRunWindowClosed(job, now)) {\n return deny(\n 'expired',\n `The approvals of this request are older than ${EXECUTE_WINDOW_DAYS} days. Open a new request.`,\n )\n }\n return ALLOWED\n}\n\n/**\n * How many days a request can run after its last approval.\n *\n * The approvers agreed to the request as the world was on that day. Later the\n * facts can change: an organization in trial becomes a paying customer. A\n * request that is older than this window needs new approvals. The rule is here\n * and not in each use case, so a new use case cannot forget it.\n *\n * A `failed` request counts from the same day. A retry is for a problem of the\n * system that is now repaired, not for a run weeks later.\n */\nexport const EXECUTE_WINDOW_DAYS = 7\n\nconst DAY_MS = 24 * 60 * 60 * 1000\n\n/**\n * Reports the end of the run window of a request.\n *\n * The window starts at the last approval. A use case that needs zero\n * approvals has none, so its window starts when the request was opened.\n */\nexport function runWindowEnd(job: ApprovalJobDto): Date {\n const start = job.acks.reduce(\n (latest, ack) => Math.max(latest, new Date(ack.date).getTime()),\n new Date(job.createdAt).getTime(),\n )\n return new Date(start + EXECUTE_WINDOW_DAYS * DAY_MS)\n}\n\n/**\n * Reports whether an `approved` or `failed` request is past its run window.\n *\n * It fails closed: a date that does not parse gives NaN, and NaN closes the\n * window. The window guards a privileged action.\n */\nexport function isRunWindowClosed(job: ApprovalJobDto, now: Date): boolean {\n if (job.status !== 'approved' && job.status !== 'failed') {\n return false\n }\n return !(runWindowEnd(job).getTime() > now.getTime())\n}\n\n/**\n * Reports whether a request ran, failed, and will not run again by itself.\n *\n * That is a `failed` request, or a failed request that control-backend then\n * wrote as `expired` at the end of its run window. Only a run raises\n * `attempts`, and an `executed` request never expires.\n */\nexport function hasFailedRun(job: ApprovalJobDto): boolean {\n return (\n job.status === 'failed' || (job.status === 'expired' && job.attempts > 0)\n )\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/*\n * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH\n * under one or more contributor license agreements. Licensed under a commercial license.\n * You may not use this file except in compliance with the commercial license.\n */\n\nimport { UNKNOWN_ACTOR_EMAIL } from '../constants/misc.const.js'\nimport type {\n ApprovalJobDto,\n ApprovalOverrideGrantDto,\n ApprovalUseCaseDto,\n} from '../dtos/ApprovalJob.dto.js'\nimport { ConsolePermissions } from '../permissions/console.permissions.js'\nimport {\n APPROVAL_USE_CASE_TYPES,\n isApprovalOverridable,\n} from './use-cases.const.js'\n\n/**\n * The rules of the approval mechanism, as pure functions.\n *\n * Three consumers run this file: control-backend decides whether to reject a\n * request, control-frontend decides whether to disable a button, and cloudbot\n * decides whether to answer. One copy means the button and the endpoint can\n * never disagree.\n *\n * Two choices in the signatures carry the design:\n *\n * - The actor is an interface with a `has` method. The backend holds an array\n * of Auth0 claims, and `useSession()` in the frontend gives a flat record of\n * booleans. Neither side has to convert to the shape of the other.\n * - `now` is a parameter. The expiry is lazy, so a stored `pending` request can\n * be expired already. A parameter makes a difference between the two clocks\n * an input that a test can set, and not a silent disagreement.\n *\n * No function here does I/O or returns a promise. The browser call site is what\n * enforces that, and it also stops a later use case from turning a list of 50\n * rows into 200 network calls.\n */\n\n/** Who is asking. See the note above on why this is an interface. */\nexport interface ApprovalActorView {\n userId: string\n /**\n * The email of the actor. It is the SECOND identity of the four-eyes rule.\n *\n * `userId` alone is not enough. One person can hold more than one identity in\n * Auth0 \u2014 a connection through SSO and a connection through a database, or an\n * account that was made again \u2014 and each one carries a different `sub`. The\n * same human then opens a request with one identity and approves it with the\n * other, which is the rule defeated.\n *\n * It is optional because a token can carry no email. See\n * `isSameActorEmail` for what that means for the comparison.\n */\n email?: string\n has(permission: string): boolean\n}\n\n/**\n * Builds the `has` of an actor from the permissions that the actor holds.\n *\n * `ConsolePermissions.APPROVALS_ALL_USE_CASES` stands in for every permission\n * that a use case asks for. Both actor builders call this function, the one of\n * control-backend and the one of the browser, so the button and the route\n * agree on it. A rule that reads `actor.has` needs no change for it, and nor\n * does a use case that a later change adds.\n */\nexport function approvalPermissionCheck(\n held: ReadonlySet<string>,\n): (permission: string) => boolean {\n const all = held.has(ConsolePermissions.APPROVALS_ALL_USE_CASES)\n return (permission) => all || held.has(permission)\n}\n\n/**\n * Reports whether two email addresses name one person.\n *\n * Two values that are absent, empty, or `UNKNOWN_ACTOR_EMAIL` are NOT one\n * person. A token with no email would otherwise make every actor equal to\n * every other actor, and the rule would refuse everybody.\n *\n * The comparison removes the spaces and the case. Auth0 gives the address as\n * the identity provider wrote it, so `A.B@camunda.com` and `a.b@camunda.com`\n * are one mailbox and must not pass as two people.\n */\nexport function isSameActorEmail(one?: string, other?: string): boolean {\n const left = comparableActorEmail(one)\n return left.length > 0 && left === comparableActorEmail(other)\n}\n\n/**\n * The address in the one form that a comparison may use.\n *\n * An absent address, an empty one and `UNKNOWN_ACTOR_EMAIL` all become an\n * empty string, because none of the three names a person. The spaces and the\n * case go as well: Auth0 gives the address as the identity provider wrote it.\n */\nfunction comparableActorEmail(value?: string): string {\n const trimmed = value?.trim().toLowerCase() ?? ''\n return trimmed === UNKNOWN_ACTOR_EMAIL ? '' : trimmed\n}\n\n/**\n * Reports whether one identity of a request names the actor.\n *\n * The `sub` of the token OR the email, because one person can hold more than\n * one Auth0 identity. Every rule that asks \"is this the same human?\" reads both\n * through this function, so a rule cannot forget the second half: `canWithdraw`\n * did, and it refused a creator whose browser and whose service resolved the\n * id from two different tokens.\n */\nexport function isSameActor(\n identity: { userId: string; email?: string },\n actor: ApprovalActorView,\n): boolean {\n return (\n identity.userId === actor.userId ||\n isSameActorEmail(identity.email, actor.email)\n )\n}\n\n/**\n * Reports whether this actor carries an address that names a person.\n *\n * A machine-to-machine token carries none: accounts-backend builds the\n * permissions of such a token from its `permissions` claim alone, and there is\n * no person behind it. See `creatorEmailRequired` of the use case.\n */\nexport function hasActorEmail(actor: ApprovalActorView): boolean {\n return comparableActorEmail(actor.email).length > 0\n}\n\n/**\n * Why an action is not allowed.\n *\n * The code is for a program: control-backend maps it to a status, and the\n * frontend can branch on it. The message is for a person, and it is shown as\n * written.\n */\nexport type ApprovalDenialCode =\n | 'forbidden'\n | 'not-pending'\n | 'self-approval'\n | 'self-rejection'\n | 'already-acknowledged'\n | 'expired'\n | 'not-creator'\n | 'not-executable'\n | 'no-reason'\n | 'too-few-acks'\n | 'no-creator-email'\n | 'no-override-grant'\n | 'not-overridable'\n\nexport type ApprovalDecision =\n | { allowed: true }\n | { allowed: false; code: ApprovalDenialCode; message: string }\n\nconst ALLOWED: ApprovalDecision = { allowed: true }\n\nfunction deny(code: ApprovalDenialCode, message: string): ApprovalDecision {\n return { allowed: false, code, message }\n}\n\n/**\n * Reports whether the time to answer a request has passed.\n *\n * Only a `pending` request expires. A request that already reached an end state\n * keeps that state, because \"expired\" would lose the answer that was given.\n */\nexport function isExpired(job: ApprovalJobDto, now: Date): boolean {\n return (\n job.status === 'pending' &&\n new Date(job.expiresAt).getTime() <= now.getTime()\n )\n}\n\n/**\n * Reports the status to show. It is `expired` once the time to answer has\n * passed, or once the run window of an `approved` or `failed` request has\n * ended. control-backend writes both, and this shows them before the write.\n */\nexport function effectiveStatus(\n job: ApprovalJobDto,\n now: Date,\n): ApprovalJobDto['status'] {\n return isExpired(job, now) || isRunWindowClosed(job, now)\n ? 'expired'\n : job.status\n}\n\n/** Reports how many more acknowledgements the request needs. */\nexport function remainingAcks(job: ApprovalJobDto): number {\n return Math.max(0, job.requiredAcks - job.acks.length)\n}\n\n/**\n * Reports whether the actor holds EVERY permission of the set.\n *\n * Every, and not one of them. A payload that raises the guard adds a\n * permission, so \"one of them\" would let the base permission alone carry a\n * request that the extra permission exists to stop.\n *\n * An EMPTY set is held by nobody. That is the opposite of what `Array.every`\n * answers, and it is deliberate: this function guards a privileged action, so\n * the empty case must fail closed. A row with no permissions is not a row that\n * everybody may run \u2014 it is a row that lost its guard, through a migration\n * that did not backfill it or a write that went around the service. Every use\n * case builds a set of at least one, so no real request meets this branch.\n */\nexport function hasEveryPermission(\n actor: ApprovalActorView,\n permissions: string[],\n): boolean {\n if (permissions.length === 0) {\n return false\n }\n return permissions.every((permission) => actor.has(permission))\n}\n\n/**\n * What a person must hold to act on a request.\n *\n * A request carries its own set, because a payload can raise the guard. A use\n * case that never does has a set of one, and it is the base permission.\n */\nexport function requiredPermissionsOf(job: ApprovalJobDto): string[] {\n return job.requiredPermissions\n}\n\n/**\n * Who can READ a request.\n *\n * Two people: one who can act on it, and the one who opened it. A Camundi who\n * holds neither has no reason to read which customer somebody proposed to\n * expire, so the list and the detail both hide it.\n *\n * The rule reads the BASE permission of the use case and not the effective set\n * of the request. A payload that raises the guard raises who can APPROVE, and\n * not who can look: a generation update with the yolo flag is visible to\n * everybody who can approve a plain one, and only the yolo permission runs it.\n * The list filters by use case in SQL, which is what makes one page of rows\n * the same page that a person is allowed to read.\n */\nexport function canView(\n job: ApprovalJobDto,\n actor: ApprovalActorView,\n useCase: ApprovalUseCaseDto,\n): ApprovalDecision {\n if (\n actor.has(useCase.requiredPermission) ||\n isSameActor(job.createdBy, actor) ||\n isOverrideGrantee(job, actor)\n ) {\n return ALLOWED\n }\n return deny('forbidden', 'You cannot see this request.')\n}\n\n/**\n * The person that a request of the two override use cases names, or\n * `undefined` for every other request.\n *\n * Four rules read it. The grantee cannot approve their own grant, also not\n * when they open it (`opensWithOwnApproval`), the grantee can read both\n * requests, and the grantee can end their own grant before its time.\n */\nexport function overrideGranteeOf(\n job: Pick<ApprovalJobDto, 'type' | 'payload'>,\n): { userId: string; email?: string } | undefined {\n if (\n job.type !== APPROVAL_USE_CASE_TYPES.grantApprovalOverride &&\n job.type !== APPROVAL_USE_CASE_TYPES.revokeApprovalOverride\n ) {\n return undefined\n }\n const { granteeUserId, granteeEmail } = job.payload\n if (typeof granteeUserId !== 'string' && typeof granteeEmail !== 'string') {\n return undefined\n }\n return {\n userId: typeof granteeUserId === 'string' ? granteeUserId : '',\n email: typeof granteeEmail === 'string' ? granteeEmail : undefined,\n }\n}\n\nfunction isOverrideGrantee(\n job: Pick<ApprovalJobDto, 'type' | 'payload'>,\n actor: ApprovalActorView,\n): boolean {\n const grantee = overrideGranteeOf(job)\n return grantee !== undefined && isSameActor(grantee, actor)\n}\n\n/**\n * Reports whether an approval override grant lets its holder act now.\n *\n * It fails closed: an absent grant, a revoked one, and a date that does not\n * parse are all inactive. control-backend reads the grant from its table at\n * every call. A token never carries it.\n */\nexport function isOverrideGrantActive(\n grant: Pick<ApprovalOverrideGrantDto, 'expiresAt' | 'revokedAt'> | undefined,\n now: Date,\n): boolean {\n if (!grant || grant.revokedAt) {\n return false\n }\n return new Date(grant.expiresAt).getTime() > now.getTime()\n}\n\n/**\n * Who can OPEN a request, and with what.\n *\n * `createPermission` is what keeps the pool that asks as small as the pool that\n * approves. A use case that leaves it absent says the opposite on purpose: the\n * approvals are the guard, and anybody signed in can propose the action.\n *\n * The two checks after it are about the request and not about the person. A\n * count below the floor of the use case, and a missing reason, are both a 400\n * and not a 403, so the words say which one the caller must repair.\n */\nexport function canCreate(\n actor: ApprovalActorView,\n useCase: ApprovalUseCaseDto,\n request: {\n reason?: string\n requiredAcks?: number\n /**\n * The effective permissions of the payload, from `permissionsFor`.\n *\n * Absent means \"the base permission alone\", which is what most use cases\n * build. Only the permissions ABOVE the base one gate opening a request:\n * see the note in the body.\n */\n requiredPermissions?: string[]\n } = {},\n): ApprovalDecision {\n // The message NAMES the permission. \"You cannot open this request\" is true\n // and useless: a Camundi who reads it cannot tell a missing permission from\n // a rule they never heard of, and the detail page names the permissions of\n // a request in the same way.\n if (useCase.createPermission && !actor.has(useCase.createPermission)) {\n return deny(\n 'forbidden',\n `Opening this request needs ${useCase.createPermission}, and you do not hold it.`,\n )\n }\n // What the PAYLOAD raised, on top of the base permission of the use case.\n // A bulk generation update with the yolo flag needs the yolo permission, and\n // without this check a person who holds the base permission alone could open\n // one. They could not approve it and could not run it, so it is not an\n // escalation \u2014 it is a request that its own author is not allowed to make,\n // waiting in the queue of the people who are.\n //\n // The BASE permission is deliberately not checked here. `createPermission`\n // above is the only gate on the person, and a use case that leaves it absent\n // means that anybody signed in can propose the action. Checking the base\n // permission too would take that choice away from every use case.\n const raised = (request.requiredPermissions ?? []).filter(\n (permission) => permission !== useCase.requiredPermission,\n )\n if (raised.length > 0 && !hasEveryPermission(actor, raised)) {\n return deny(\n 'forbidden',\n `This payload needs ${raised.join(' and ')}, and you do not hold it.`,\n )\n }\n // A request that needs no approval RUNS when it is opened, so the person who\n // opens it must hold what running it needs. Every other use case leaves this\n // to the approvals, which is why the base permission is not checked above.\n // With a floor of zero there is no approval to leave it to.\n const toRun = request.requiredPermissions ?? [useCase.requiredPermission]\n // A service that opens a request of its own use case holds the\n // `createPermission` checked above, and that is the guard of the run. Without\n // a `createPermission` there is no such guard, so the flag lifts nothing.\n const openedBySystem = useCase.openedBySystem && !!useCase.createPermission\n if (\n resolveRequiredAcks(useCase, request.requiredAcks) === 0 &&\n !useCase.openToEveryone &&\n !openedBySystem &&\n !hasEveryPermission(actor, toRun)\n ) {\n // The message NAMES the permission, and it says why opening needs it. A\n // Camundi who reads \"you cannot open this request\" for an action that\n // needs no approval cannot tell which of the two rules refused them.\n return deny(\n 'forbidden',\n `This request needs no approval, so opening it runs it. That needs ` +\n `${toRun.join(' and ')}, and you do not hold it.`,\n )\n }\n // The action of this use case carries the address of the person who asked,\n // so a caller with no address must be refused HERE. The alternative is a\n // request that looks ordinary, collects its approvals over days, and then\n // fails because the other service cannot find a user called \"unknown\".\n if (useCase.creatorEmailRequired && !hasActorEmail(actor)) {\n return deny(\n 'no-creator-email',\n 'This action needs the email address of the person who asks for it, ' +\n 'and your session carries none. Sign in as a person to open this ' +\n 'request.',\n )\n }\n if (\n needsReason(actor, useCase, {\n requiredAcks: request.requiredAcks,\n requiredPermissions: toRun,\n }) &&\n (request.reason ?? '').trim().length === 0\n ) {\n return deny('no-reason', 'Give a reason to open this request.')\n }\n if (\n request.requiredAcks !== undefined &&\n request.requiredAcks < useCase.minAcks\n ) {\n return deny(\n 'too-few-acks',\n `This action needs at least ${useCase.minAcks} approvals.`,\n )\n }\n return ALLOWED\n}\n\n/**\n * Reports whether the opener approves a new request when it is created.\n *\n * The opener approves when the request needs an approval and the opener holds\n * every run permission of it. `APPROVAL_RULES` counts this approval. A request\n * that needs no approval gets none, so its `approvedBy` stays empty.\n *\n * When `request` is given, the grantee of an approval override grant does not\n * approve the grant that they open for themselves, as `canAcknowledge`\n * refuses their approval later. Every approval of such a request then comes\n * from another person.\n */\nexport function opensWithOwnApproval(\n actor: ApprovalActorView,\n requiredAcks: number,\n requiredPermissions: string[],\n request?: Pick<ApprovalJobDto, 'type' | 'payload'>,\n): boolean {\n if (request !== undefined && isOverrideGrantee(request, actor)) {\n return false\n }\n return requiredAcks > 0 && hasEveryPermission(actor, requiredPermissions)\n}\n\n/**\n * Reports whether a new request needs a reason.\n *\n * A request needs no reason when its opener holds every run permission and\n * needs no second person. Nobody else reads the reason before the action\n * runs, and the audit record names the opener.\n */\nexport function needsReason(\n actor: ApprovalActorView,\n useCase: ApprovalUseCaseDto,\n request: { requiredAcks?: number; requiredPermissions?: string[] } = {},\n): boolean {\n if (!useCase.reasonRequired) {\n return false\n }\n return !(\n resolveRequiredAcks(useCase, request.requiredAcks) <= 1 &&\n hasEveryPermission(\n actor,\n request.requiredPermissions ?? [useCase.requiredPermission],\n )\n )\n}\n\n/**\n * The approvals that a new request asks for.\n *\n * The caller names a number or names none. A number below the floor is refused\n * by `canCreate`, and this function holds the answer inside the range anyway:\n * the two run in the same call, and a floor that only one of them knows is a\n * floor that a later caller can step over.\n */\nexport function resolveRequiredAcks(\n useCase: ApprovalUseCaseDto,\n requested?: number,\n): number {\n if (requested === undefined) {\n return Math.max(useCase.defaultAcks, useCase.minAcks)\n }\n return Math.max(Math.trunc(requested), useCase.minAcks)\n}\n\n/**\n * The four-eyes rule.\n *\n * The bans are in the order a person meets them, and each one has its own\n * message: \"you already approved this\" and \"you cannot approve your own\n * request\" are different mistakes, and one message for both helps nobody.\n *\n * Each of the two bans on a person reads BOTH identities: the `sub` of the\n * token, and the email. See `ApprovalActorView.email` for why one of them is\n * not enough.\n */\nexport function canAcknowledge(\n job: ApprovalJobDto,\n actor: ApprovalActorView,\n now: Date,\n): ApprovalDecision {\n if (!hasEveryPermission(actor, job.requiredPermissions)) {\n return deny('forbidden', 'You cannot approve this request.')\n }\n if (isExpired(job, now)) {\n return deny(\n 'expired',\n 'This request expired and can no longer be approved.',\n )\n }\n if (job.status !== 'pending') {\n return deny(\n 'not-pending',\n `This request is ${job.status} and can no longer be approved.`,\n )\n }\n if (isSameActor(job.createdBy, actor)) {\n return deny(\n 'self-approval',\n 'You opened this request, so you cannot approve it.',\n )\n }\n if (isOverrideGrantee(job, actor)) {\n return deny(\n 'self-approval',\n 'This request gives you an override grant, so you cannot approve it.',\n )\n }\n if (job.acks.some((ack) => isSameActor(ack, actor))) {\n return deny('already-acknowledged', 'You already approved this request.')\n }\n return ALLOWED\n}\n\n/**\n * A rejection needs the same permission as an acknowledgement, and a reason.\n *\n * Two people answer a request, and each one has ONE verb. Somebody else\n * approves or rejects it; the person who opened it withdraws it. The creator is\n * refused here for the same reason `canAcknowledge` refuses them: one person\n * must not be both sides of a four-eyes decision. The message names the button\n * that they do have, and the trail loses nothing, because a withdrawal writes\n * its reason into the same field.\n *\n * The ORDER of the checks is the order that a person meets them, and the state\n * of the request comes before the reason. A closed request must answer\n * \"already closed\" whatever the caller typed: with the reason first, a page\n * that had no reason yet read `no-reason` from a rejected request, and the\n * Reject button of control-frontend stayed enabled on a request that nothing\n * could reject any more.\n */\nexport function canReject(\n job: ApprovalJobDto,\n actor: ApprovalActorView,\n now: Date,\n reason: string,\n): ApprovalDecision {\n if (!hasEveryPermission(actor, job.requiredPermissions)) {\n return deny('forbidden', 'You cannot reject this request.')\n }\n if (isExpired(job, now)) {\n return deny(\n 'expired',\n 'This request expired and can no longer be rejected.',\n )\n }\n if (job.status !== 'pending') {\n return deny(\n 'not-pending',\n `This request is ${job.status} and can no longer be rejected.`,\n )\n }\n if (isSameActor(job.createdBy, actor)) {\n return deny(\n 'self-rejection',\n 'You opened this request, so withdraw it instead of rejecting it.',\n )\n }\n if (reason.trim().length === 0) {\n return deny('no-reason', 'Give a reason to reject this request.')\n }\n return ALLOWED\n}\n\n/**\n * Only the person who opened a request can take it back.\n *\n * BOTH identities, as every other rule of this file reads them. One person can\n * hold two Auth0 identities with two different `sub` values, and a rule that\n * read the id alone refused the creator their own withdrawal as soon as the\n * browser and the service resolved that id from two different tokens.\n */\nexport function canWithdraw(\n job: ApprovalJobDto,\n actor: ApprovalActorView,\n now: Date,\n): ApprovalDecision {\n if (!isSameActor(job.createdBy, actor)) {\n return deny(\n 'not-creator',\n 'Only the person who opened a request can withdraw it.',\n )\n }\n if (isExpired(job, now)) {\n return deny(\n 'expired',\n 'This request expired and can no longer be withdrawn.',\n )\n }\n if (job.status !== 'pending') {\n return deny(\n 'not-pending',\n `This request is ${job.status} and can no longer be withdrawn.`,\n )\n }\n return ALLOWED\n}\n\n/**\n * Runs a request that has its approvals.\n *\n * This is the button of the explicit mode. `failed` is executable again,\n * because a failure is often a problem of the system that is now repaired, and\n * asking for the approvals a second time helps nobody. Both run only inside\n * the run window, see `EXECUTE_WINDOW_DAYS`.\n *\n * ANY holder of the permission may press it, and not only the person who\n * opened the request. The decision was made when the request reached\n * `approved`, when the row got all the approvals that the request asks for.\n * One of them can be the approval of the opener at create (see\n * `opensWithOwnApproval`), and `canAcknowledge` keeps each person to one\n * approval. The button only carries out what these people already agreed to.\n * Holding the button to the creator would let one Camundi\n * on holiday freeze an approved action, and a use case that must not run\n * without its author has `executionMode: 'auto'` instead.\n *\n * Who PRESSED it and who ASKED for it are two different facts, and both are\n * kept: the audit trail of control-backend records the actor of the run, and\n * an action that names a person to another service names the creator. See\n * `onBehalfOf` of `extend-trial`.\n */\nexport function canExecute(\n job: ApprovalJobDto,\n actor: ApprovalActorView,\n // A default, because the package is published and a caller from before\n // the run window passes two arguments.\n now: Date = new Date(),\n): ApprovalDecision {\n // The grantee may end their own grant early, with no permission. Ending a\n // grant takes power away, so it needs no second person.\n // A `failed` revoke counts too: the reconciler does not run a failed row\n // again, so the grantee must be able to retry it.\n const endsOwnGrant =\n (job.status === 'scheduled' || job.status === 'failed') &&\n job.type === APPROVAL_USE_CASE_TYPES.revokeApprovalOverride &&\n isOverrideGrantee(job, actor)\n if (!endsOwnGrant && !hasEveryPermission(actor, job.requiredPermissions)) {\n return deny('forbidden', 'You cannot run this request.')\n }\n // The grantee cannot run the request that gives them their grant, as\n // `canAcknowledge` refuses their approval. A retry of a `failed` grant would\n // let them pick the time when the grant starts.\n if (\n job.type === APPROVAL_USE_CASE_TYPES.grantApprovalOverride &&\n isOverrideGrantee(job, actor)\n ) {\n return deny(\n 'self-approval',\n 'This request gives you an override grant, so you cannot run it.',\n )\n }\n // control-backend writes `expired` before it answers a read, so the row\n // that a person sees is usually already `expired` and not past its window.\n if (job.status === 'expired') {\n return deny('expired', 'This request is expired. Open a new request.')\n }\n // A `scheduled` request runs by itself at `runAt`. To run it before that\n // time needs the same permissions as any other run.\n if (\n job.status !== 'approved' &&\n job.status !== 'failed' &&\n job.status !== 'scheduled'\n ) {\n return deny(\n 'not-executable',\n `This request is ${job.status} and cannot be run.`,\n )\n }\n if (isRunWindowClosed(job, now)) {\n return deny(\n 'expired',\n `The approvals of this request are older than ${EXECUTE_WINDOW_DAYS} days. Open a new request.`,\n )\n }\n return ALLOWED\n}\n\n/**\n * Why a request that an override approved can no longer run: the grant that\n * the override used ended. The revoke of a grant writes it as `closedReason`\n * on each such request, and `canRunOverridden` answers it.\n */\nexport const OVERRIDE_GRANT_ENDED_MESSAGE =\n 'The approval override grant that ran this request ended. Open a new request.'\n\n/**\n * Reports whether a request that an override ran can still run.\n *\n * An override of a request in the explicit mode moves it to `approved`, and a\n * person runs it later. A failed run can also run again. The grant is the only\n * thing that stood in for the approvals, so each run needs it to be active\n * still: a revoke or the end of the grant stops the request too.\n *\n * `grant` is the grant that the override used (`job.overrideGrantId`), which\n * the caller reads from the grant table at the run. A later grant of the same\n * person does not count: a revoke ends the requests that its grant ran, and a\n * new grant must not bring them back. A request with no override is not\n * affected. A type that joined `APPROVAL_OVERRIDE_DENYLIST` after the\n * override needs its approvals, as `canOverride` says.\n */\nexport function canRunOverridden(\n job: Pick<ApprovalJobDto, 'type' | 'overriddenBy'>,\n grant: Pick<ApprovalOverrideGrantDto, 'expiresAt' | 'revokedAt'> | undefined,\n now: Date,\n): ApprovalDecision {\n if (!job.overriddenBy) {\n return ALLOWED\n }\n if (!isApprovalOverridable(job.type)) {\n return deny(\n 'not-overridable',\n 'An override cannot run this type of request. It needs its approvals.',\n )\n }\n if (isOverrideGrantActive(grant, now)) {\n return ALLOWED\n }\n return deny('no-override-grant', OVERRIDE_GRANT_ENDED_MESSAGE)\n}\n\n/**\n * Runs a `pending` request with an approval override grant, in place of the\n * approvals that it still needs.\n *\n * `grant` is the active grant of the actor. The caller reads it from the\n * grant table at every call, and never from a token: a token keeps its\n * permissions until it expires, and a revoked grant must stop at once.\n *\n * The creator may override their own request, because that is what a grant\n * is for. The actor still needs the permissions of the request: a grant\n * takes the place of the second person, and not of the permission. A use case\n * in `APPROVAL_OVERRIDE_DENYLIST` always needs its approvals.\n */\nexport function canOverride(\n job: ApprovalJobDto,\n actor: ApprovalActorView,\n now: Date,\n args: {\n grant?: Pick<\n ApprovalOverrideGrantDto,\n 'userId' | 'email' | 'expiresAt' | 'revokedAt'\n >\n reason: string\n },\n): ApprovalDecision {\n if (\n !args.grant ||\n !isOverrideGrantActive(args.grant, now) ||\n !isSameActor(args.grant, actor)\n ) {\n return deny(\n 'no-override-grant',\n 'You hold no active approval override grant.',\n )\n }\n if (!isApprovalOverridable(job.type)) {\n return deny(\n 'not-overridable',\n 'An override cannot run this type of request. It needs its approvals.',\n )\n }\n if (!hasEveryPermission(actor, job.requiredPermissions)) {\n return deny('forbidden', 'You cannot run this request.')\n }\n if (isExpired(job, now)) {\n return deny('expired', 'This request expired and can no longer be run.')\n }\n if (job.status !== 'pending') {\n return deny(\n 'not-pending',\n `This request is ${job.status} and cannot be run with an override.`,\n )\n }\n if (args.reason.trim().length === 0) {\n return deny(\n 'no-reason',\n 'Give a reason to run this request with an override.',\n )\n }\n return ALLOWED\n}\n\n/**\n * How many days a request can run after its last approval.\n *\n * The approvers agreed to the request as the world was on that day. Later the\n * facts can change: an organization in trial becomes a paying customer. A\n * request that is older than this window needs new approvals. The rule is here\n * and not in each use case, so a new use case cannot forget it.\n *\n * A `failed` request counts from the same day. A retry is for a problem of the\n * system that is now repaired, not for a run weeks later.\n */\nexport const EXECUTE_WINDOW_DAYS = 7\n\nconst DAY_MS = 24 * 60 * 60 * 1000\n\n/**\n * Reports the end of the run window of a request.\n *\n * The window starts at the last decision that let the request run: the last\n * approval, the override (`overriddenAt`), or the time of a `scheduled`\n * request (`runAt`). A use case that needs zero approvals has none of them,\n * so its window starts when the request was opened.\n */\nexport function runWindowEnd(job: ApprovalJobDto): Date {\n const decisions = [\n ...job.acks.map((ack) => ack.date),\n job.overriddenAt,\n job.runAt,\n ]\n const start = decisions.reduce(\n (latest, date) =>\n date === undefined ? latest : Math.max(latest, new Date(date).getTime()),\n new Date(job.createdAt).getTime(),\n )\n return new Date(start + EXECUTE_WINDOW_DAYS * DAY_MS)\n}\n\n/**\n * Reports whether an `approved` or `failed` request is past its run window.\n *\n * It fails closed: a date that does not parse gives NaN, and NaN closes the\n * window. The window guards a privileged action.\n */\nexport function isRunWindowClosed(job: ApprovalJobDto, now: Date): boolean {\n if (job.status !== 'approved' && job.status !== 'failed') {\n return false\n }\n return !(runWindowEnd(job).getTime() > now.getTime())\n}\n\n/**\n * Reports whether a request ran, failed, and will not run again by itself.\n *\n * That is a `failed` request, or a failed request that control-backend then\n * wrote as `expired` at the end of its run window. Only a run raises\n * `attempts`, and an `executed` request never expires.\n */\nexport function hasFailedRun(job: ApprovalJobDto): boolean {\n return (\n job.status === 'failed' || (job.status === 'expired' && job.attempts > 0)\n )\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -28,6 +28,7 @@ export declare const APPROVAL_USE_CASE_TYPES: {
28
28
  readonly changeOrgLimits: "change-org-limits";
29
29
  readonly deleteSsoConnection: "delete-sso-connection";
30
30
  readonly changeSalesPlan: "change-sales-plan";
31
+ readonly llmBudgetTopUp: "llm-budget-top-up";
31
32
  readonly requestInNegotiation: "request-in-negotiation";
32
33
  readonly deleteOrganization: "delete-organization";
33
34
  readonly deleteUser: "delete-user";
@@ -52,16 +53,31 @@ export declare const APPROVAL_USE_CASE_TYPES: {
52
53
  readonly deleteClusterCorpses: "delete-cluster-corpses";
53
54
  readonly forceDeleteClusterPlanType: "force-delete-cluster-plan-type";
54
55
  readonly deleteApiClient: "delete-api-client";
56
+ readonly grantApprovalOverride: "grant-approval-override";
57
+ readonly revokeApprovalOverride: "revoke-approval-override";
55
58
  };
59
+ /** The longest approval override grant, in hours: 14 days. */
60
+ export declare const APPROVAL_OVERRIDE_MAX_HOURS: number;
61
+ /** The duration that the form of control-frontend offers first: one day. */
62
+ export declare const APPROVAL_OVERRIDE_DEFAULT_HOURS = 24;
56
63
  /**
57
- * The approvals that a change of the feature flag table needs on prod.
64
+ * The use cases that an approval override grant can NOT run.
58
65
  *
59
- * A flag there turns a feature on or off for every customer, and a person who
60
- * holds `aafw` could do it alone. Two other Camundis stand in every change,
61
- * the same floor as a cluster bulk operation on prod
62
- * (`CLUSTER_BULK_OPERATION_MIN_ACKS`). Int needs one.
66
+ * Every other use case is overridable. These change who holds a permission,
67
+ * the override mechanism itself, the feature flags of a stage, the address
68
+ * that a person signs in with, or delete customer data, a backup, a sign-in
69
+ * path, or a whole fleet of clusters. None of them is an emergency that one
70
+ * Camundi must fix alone.
71
+ *
72
+ * The list fails open: a use case that is not here IS overridable. Thus a new
73
+ * use case that deletes or that changes access must be added here. Its route
74
+ * in accounts-backend or console-backend then refuses `overriddenBy` by
75
+ * itself: `approvalActorSchemaFor` of their `AdminApprovalsRouter.ts` reads
76
+ * this list.
63
77
  */
64
- export declare const FEATURE_FLAG_MIN_ACKS_PROD = 2;
78
+ export declare const APPROVAL_OVERRIDE_DENYLIST: readonly string[];
79
+ /** isApprovalOverridable reports whether a grant can run a request of this type. */
80
+ export declare const isApprovalOverridable: (type: string) => boolean;
65
81
  /**
66
82
  * The most personal organizations that one Camundi can request.
67
83
  *
@@ -111,6 +127,20 @@ export declare const PLATFORM_ROLE_CHANGE_MAX_ROLES = 10;
111
127
  * a request readable for the person who approves it.
112
128
  */
113
129
  export declare const ORG_LIMIT_TEXT_MAX_LENGTH = 2000;
130
+ /**
131
+ * LLM_BUDGET_TOP_UP_THRESHOLD is the share of the budget that the spend of a
132
+ * managed LLM key must reach before a top-up is the normal case.
133
+ *
134
+ * A request below it is still possible, but the person who asks must confirm
135
+ * it, and the request says so to the approver. Accounts refuses a top-up
136
+ * below it that does not carry that confirmation.
137
+ */
138
+ export declare const LLM_BUDGET_TOP_UP_THRESHOLD = 0.9;
139
+ /**
140
+ * isLlmSpendBelowTopUpThreshold reports whether a top-up of a managed LLM key
141
+ * needs the explicit confirmation of `LLM_BUDGET_TOP_UP_THRESHOLD`.
142
+ */
143
+ export declare const isLlmSpendBelowTopUpThreshold: (spend: number, maxBudget: number) => boolean;
114
144
  /**
115
145
  * The most users that one purge-users request deletes.
116
146
  *
@@ -12,15 +12,15 @@ const APPROVAL_USE_CASE_TYPES = {
12
12
  clusterBulkSleepWakeup: "cluster-bulk-sleep-wakeup",
13
13
  clusterBulkDeletion: "cluster-bulk-deletion",
14
14
  clusterBulkSpecUpdate: "cluster-bulk-spec-update",
15
- // The second group, behind `control.approvalAdminActions`.
15
+ // The second group, behind `control.camundiApprovalFlow`.
16
16
  changePlatformRoles: "change-platform-roles",
17
17
  deleteInternalBackup: "delete-internal-backup",
18
18
  changeOrgLimits: "change-org-limits",
19
19
  deleteSsoConnection: "delete-sso-connection",
20
20
  changeSalesPlan: "change-sales-plan",
21
- // Behind its own flag, `control.approvalInNegotiation`.
21
+ llmBudgetTopUp: "llm-budget-top-up",
22
22
  requestInNegotiation: "request-in-negotiation",
23
- // The third group, behind `control.approvalAccountActions`.
23
+ // The third group, behind `control.camundiApprovalFlow`.
24
24
  deleteOrganization: "delete-organization",
25
25
  deleteUser: "delete-user",
26
26
  changeUserEmail: "change-user-email",
@@ -40,15 +40,15 @@ const APPROVAL_USE_CASE_TYPES = {
40
40
  createCustomHardwareSizing: "create-custom-hardware-sizing",
41
41
  removeCustomHardwareSizing: "remove-custom-hardware-sizing",
42
42
  // The mutable versions of one or more generations, behind
43
- // `control.changeGenerationVersions`. Console restarts every cluster of
43
+ // `control.camundiApprovalFlow`. Console restarts every cluster of
44
44
  // each generation, which is why the write needs a second Camundi.
45
45
  changeGenerationVersions: "change-generation-versions",
46
46
  // The name and the env vars of one generation, behind
47
- // `control.editGeneration`. Console sends a new spec to every cluster of
47
+ // `control.camundiApprovalFlow`. Console sends a new spec to every cluster of
48
48
  // the generation, which is why the write needs a second Camundi.
49
49
  editGeneration: "edit-generation",
50
50
  // A new enterprise customer: a production and a development organization
51
- // in one step, behind `control.automatedEnterpriseOnboarding`. See
51
+ // in one step, behind `control.camundiApprovalFlow`. See
52
52
  // `automated-enterprise-onboarding.ts` in control-backend.
53
53
  automatedEnterpriseOnboarding: "automated-enterprise-onboarding",
54
54
  // Open to every Camundi, runs on dev only, and needs no approval. The two
@@ -59,31 +59,56 @@ const APPROVAL_USE_CASE_TYPES = {
59
59
  // or deactivates a cluster modification config of one cluster.
60
60
  selfServiceClusterModification: "self-service-cluster-modification",
61
61
  // The enterprise onboarding of one organization, behind
62
- // `control.onboardToEnterpriseApproval`: the contract, the plan type of
62
+ // `control.camundiApprovalFlow`: the contract, the plan type of
63
63
  // each cluster, and the reservations, in one request.
64
64
  onboardToEnterprise: "onboard-to-enterprise",
65
65
  // The plan type and the hardware packages of ONE cluster, behind
66
- // `control.changeClusterPlanType`. The bulk plan type update beside it
67
- // checks no reservation and moves no cluster plan, so this one is not a
68
- // duplicate of it. See `change-cluster-plan-type.ts` in control-backend.
66
+ // `control.camundiApprovalFlow`. The bulk plan type update beside it also
67
+ // checks the reservations and moves the cluster plan, but each cluster
68
+ // keeps its packages there. This one can change the packages, and it has a
69
+ // stale guard. See `change-cluster-plan-type.ts` in control-backend.
69
70
  changeClusterPlanType: "change-cluster-plan-type",
70
71
  // The deletion of one cluster whose TTL ran out. Only the delete cron of
71
72
  // console-backend opens it, and it needs no approval. It is a record of the
72
73
  // deletion. See `cluster-ttl-deletion.ts` in control-backend.
73
74
  clusterTtlDeletion: "cluster-ttl-deletion",
74
75
  // The deletion of the DB corpses or of the Kubernetes corpses that console
75
- // finds, behind `control.deleteClusterCorpses`. See
76
+ // finds, behind `control.camundiApprovalFlow`. See
76
77
  // `delete-cluster-corpses.ts` in control-backend.
77
78
  deleteClusterCorpses: "delete-cluster-corpses",
78
79
  // The force delete of a cluster plan type and of every cluster on it,
79
- // behind `control.forceDeleteClusterPlanType`. See
80
+ // behind `control.camundiApprovalFlow`. See
80
81
  // `force-delete-cluster-plan-type.ts` in control-backend.
81
82
  forceDeleteClusterPlanType: "force-delete-cluster-plan-type",
82
83
  // The deletion of one Console API client of an organization. See
83
84
  // `delete-api-client.ts` in control-backend.
84
- deleteApiClient: "delete-api-client"
85
+ deleteApiClient: "delete-api-client",
86
+ // A time-limited grant that lets one Camundi run a pending request with no
87
+ // approval, and the scheduled request that ends it. Behind
88
+ // `control.approvalOverride`. See `approval-override.ts` in control-backend.
89
+ grantApprovalOverride: "grant-approval-override",
90
+ revokeApprovalOverride: "revoke-approval-override"
85
91
  };
86
- const FEATURE_FLAG_MIN_ACKS_PROD = 2;
92
+ const APPROVAL_OVERRIDE_MAX_HOURS = 14 * 24;
93
+ const APPROVAL_OVERRIDE_DEFAULT_HOURS = 24;
94
+ const APPROVAL_OVERRIDE_DENYLIST = [
95
+ APPROVAL_USE_CASE_TYPES.changePlatformRoles,
96
+ APPROVAL_USE_CASE_TYPES.grantApprovalOverride,
97
+ APPROVAL_USE_CASE_TYPES.revokeApprovalOverride,
98
+ APPROVAL_USE_CASE_TYPES.changeFeatureFlag,
99
+ APPROVAL_USE_CASE_TYPES.purgeUsers,
100
+ APPROVAL_USE_CASE_TYPES.deleteUser,
101
+ APPROVAL_USE_CASE_TYPES.deleteOrganization,
102
+ APPROVAL_USE_CASE_TYPES.clusterBulkDeletion,
103
+ APPROVAL_USE_CASE_TYPES.forceDeleteClusterPlanType,
104
+ APPROVAL_USE_CASE_TYPES.releaseTrainPublish,
105
+ APPROVAL_USE_CASE_TYPES.releaseTrainRollback,
106
+ APPROVAL_USE_CASE_TYPES.deleteSsoConnection,
107
+ APPROVAL_USE_CASE_TYPES.changeUserEmail,
108
+ APPROVAL_USE_CASE_TYPES.deleteInternalBackup,
109
+ APPROVAL_USE_CASE_TYPES.deleteApiClient
110
+ ];
111
+ const isApprovalOverridable = (type) => !APPROVAL_OVERRIDE_DENYLIST.includes(type);
87
112
  const PERSONAL_ORGANIZATION_MAX_PER_REQUESTER = 10;
88
113
  const PERSONAL_ORGANIZATION_NAME_MIN_LENGTH = 3;
89
114
  const PERSONAL_ORGANIZATION_NAME_MAX_LENGTH = 100;
@@ -91,6 +116,8 @@ const EXTEND_TRIAL_MIN_DAYS = 15;
91
116
  const EXTEND_TRIAL_MAX_DAYS = 61;
92
117
  const PLATFORM_ROLE_CHANGE_MAX_ROLES = 10;
93
118
  const ORG_LIMIT_TEXT_MAX_LENGTH = 2e3;
119
+ const LLM_BUDGET_TOP_UP_THRESHOLD = 0.9;
120
+ const isLlmSpendBelowTopUpThreshold = (spend, maxBudget) => spend < maxBudget * LLM_BUDGET_TOP_UP_THRESHOLD;
94
121
  const PURGE_USERS_MAX_USERS = 1e3;
95
122
  const PURGE_USERS_REQUIRED_PERMISSIONS = [
96
123
  AccountsPermissions.GOD_PURGE_USERS,
@@ -115,6 +142,9 @@ const GENERATION_VERSION_VALUE_MAX_LENGTH = 2e3;
115
142
  export {
116
143
  APPROVAL_EMAIL_MAX_LENGTH,
117
144
  APPROVAL_EMAIL_PATTERN,
145
+ APPROVAL_OVERRIDE_DEFAULT_HOURS,
146
+ APPROVAL_OVERRIDE_DENYLIST,
147
+ APPROVAL_OVERRIDE_MAX_HOURS,
118
148
  APPROVAL_USE_CASE_TYPES,
119
149
  ENTERPRISE_ONBOARDING_METRIC_MAX,
120
150
  ENTERPRISE_ONBOARDING_NAME_MAX_LENGTH,
@@ -124,9 +154,9 @@ export {
124
154
  ENTERPRISE_ONBOARDING_TERM_MIN_MONTHS,
125
155
  EXTEND_TRIAL_MAX_DAYS,
126
156
  EXTEND_TRIAL_MIN_DAYS,
127
- FEATURE_FLAG_MIN_ACKS_PROD,
128
157
  GENERATION_VERSIONS_MAX_GENERATIONS,
129
158
  GENERATION_VERSION_VALUE_MAX_LENGTH,
159
+ LLM_BUDGET_TOP_UP_THRESHOLD,
130
160
  ORG_LIMIT_TEXT_MAX_LENGTH,
131
161
  PERSONAL_ORGANIZATION_MAX_PER_REQUESTER,
132
162
  PERSONAL_ORGANIZATION_NAME_MAX_LENGTH,
@@ -136,6 +166,8 @@ export {
136
166
  PURGE_USERS_REQUIRED_PERMISSIONS,
137
167
  USER_METADATA_EXPECTED_MAX_LENGTH,
138
168
  USER_METADATA_MAX_LENGTH,
139
- isCalendarDay
169
+ isApprovalOverridable,
170
+ isCalendarDay,
171
+ isLlmSpendBelowTopUpThreshold
140
172
  };
141
173
  //# sourceMappingURL=use-cases.const.js.map
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/approvals/use-cases.const.ts"],
4
- "sourcesContent": ["/*\n * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH\n * under one or more contributor license agreements. Licensed under a commercial license.\n * You may not use this file except in compliance with the commercial license.\n */\n\nimport { AccountsPermissions } from '../permissions/accounts.permissions.js'\n\n/**\n * The names and the value ranges of the registered use cases.\n *\n * control-backend owns the registry, and a frontend reads it at run time\n * (`GET /api/approvals/use-cases`). Two things cannot wait for that answer:\n *\n * - **The identifier**, because a caller must NAME a use case to find it in the\n * registry, and to open a request of it.\n * - **The range of a payload field**, because a form holds the value before any\n * call goes out.\n *\n * Both live here, so the schema of the service and the form of the browser can\n * never hold different numbers.\n */\n\n/** The identifier of each use case, on the wire and in the table. */\nexport const APPROVAL_USE_CASE_TYPES = {\n sendToExpiredTrial: 'send-to-expired-trial',\n extendTrial: 'extend-trial',\n moveClusterOrg: 'move-cluster-org',\n // One use case for each type of a cluster bulk operation. The types need\n // different permissions and different approval counts, and a use case holds\n // one of each. See `cluster-bulk-operation.ts` beside this file.\n clusterBulkGenerationUpdate: 'cluster-bulk-generation-update',\n clusterBulkPlanUpdate: 'cluster-bulk-plan-update',\n clusterBulkPlanTypeUpdate: 'cluster-bulk-plan-type-update',\n clusterBulkSleepWakeup: 'cluster-bulk-sleep-wakeup',\n clusterBulkDeletion: 'cluster-bulk-deletion',\n clusterBulkSpecUpdate: 'cluster-bulk-spec-update',\n // The second group, behind `control.approvalAdminActions`.\n changePlatformRoles: 'change-platform-roles',\n deleteInternalBackup: 'delete-internal-backup',\n changeOrgLimits: 'change-org-limits',\n deleteSsoConnection: 'delete-sso-connection',\n changeSalesPlan: 'change-sales-plan',\n // Behind its own flag, `control.approvalInNegotiation`.\n requestInNegotiation: 'request-in-negotiation',\n // The third group, behind `control.approvalAccountActions`.\n deleteOrganization: 'delete-organization',\n deleteUser: 'delete-user',\n changeUserEmail: 'change-user-email',\n purgeUsers: 'purge-users',\n updateUserMetadata: 'update-user-metadata',\n // A change of the feature flag table, on a stage where\n // `isFeatureFlagApprovalRequired` is true.\n changeFeatureFlag: 'change-feature-flag',\n // The publish and the rollback of a release-train generation. See\n // `release-train.ts` beside this file.\n releaseTrainPublish: 'release-train-publish',\n releaseTrainRollback: 'release-train-rollback',\n // Open to every Camundi, and it needs no approval on any stage.\n createPersonalOrganisation: 'create-personal-organisation',\n // Open to every Camundi, runs on dev only, and needs no approval. The two\n // use cases of custom hardware sizing.\n createCustomHardwareSizing: 'create-custom-hardware-sizing',\n removeCustomHardwareSizing: 'remove-custom-hardware-sizing',\n // The mutable versions of one or more generations, behind\n // `control.changeGenerationVersions`. Console restarts every cluster of\n // each generation, which is why the write needs a second Camundi.\n changeGenerationVersions: 'change-generation-versions',\n // The name and the env vars of one generation, behind\n // `control.editGeneration`. Console sends a new spec to every cluster of\n // the generation, which is why the write needs a second Camundi.\n editGeneration: 'edit-generation',\n // A new enterprise customer: a production and a development organization\n // in one step, behind `control.automatedEnterpriseOnboarding`. See\n // `automated-enterprise-onboarding.ts` in control-backend.\n automatedEnterpriseOnboarding: 'automated-enterprise-onboarding',\n // Open to every Camundi, runs on dev only, and needs no approval. The two\n // use cases of custom generations.\n createCustomGeneration: 'create-custom-generation',\n removeCustomGeneration: 'remove-custom-generation',\n // Open to org owners, runs on dev only, and needs no approval. It applies\n // or deactivates a cluster modification config of one cluster.\n selfServiceClusterModification: 'self-service-cluster-modification',\n // The enterprise onboarding of one organization, behind\n // `control.onboardToEnterpriseApproval`: the contract, the plan type of\n // each cluster, and the reservations, in one request.\n onboardToEnterprise: 'onboard-to-enterprise',\n // The plan type and the hardware packages of ONE cluster, behind\n // `control.changeClusterPlanType`. The bulk plan type update beside it\n // checks no reservation and moves no cluster plan, so this one is not a\n // duplicate of it. See `change-cluster-plan-type.ts` in control-backend.\n changeClusterPlanType: 'change-cluster-plan-type',\n // The deletion of one cluster whose TTL ran out. Only the delete cron of\n // console-backend opens it, and it needs no approval. It is a record of the\n // deletion. See `cluster-ttl-deletion.ts` in control-backend.\n clusterTtlDeletion: 'cluster-ttl-deletion',\n // The deletion of the DB corpses or of the Kubernetes corpses that console\n // finds, behind `control.deleteClusterCorpses`. See\n // `delete-cluster-corpses.ts` in control-backend.\n deleteClusterCorpses: 'delete-cluster-corpses',\n // The force delete of a cluster plan type and of every cluster on it,\n // behind `control.forceDeleteClusterPlanType`. See\n // `force-delete-cluster-plan-type.ts` in control-backend.\n forceDeleteClusterPlanType: 'force-delete-cluster-plan-type',\n // The deletion of one Console API client of an organization. See\n // `delete-api-client.ts` in control-backend.\n deleteApiClient: 'delete-api-client',\n} as const\n\n/**\n * The approvals that a change of the feature flag table needs on prod.\n *\n * A flag there turns a feature on or off for every customer, and a person who\n * holds `aafw` could do it alone. Two other Camundis stand in every change,\n * the same floor as a cluster bulk operation on prod\n * (`CLUSTER_BULK_OPERATION_MIN_ACKS`). Int needs one.\n */\nexport const FEATURE_FLAG_MIN_ACKS_PROD = 2\n\n/**\n * The most personal organizations that one Camundi can request.\n *\n * The request runs with no approval, so this cap is the only guard against a\n * loop that creates organizations without end. It counts the requests that a\n * Camundi opened, and a person who needs more asks a Camundi who holds\n * `AccountsPermissions.CREATE_ORG`.\n */\nexport const PERSONAL_ORGANIZATION_MAX_PER_REQUESTER = 10\n\n/** The shortest and longest name of a personal organization. */\nexport const PERSONAL_ORGANIZATION_NAME_MIN_LENGTH = 3\nexport const PERSONAL_ORGANIZATION_NAME_MAX_LENGTH = 100\n\nexport type ApprovalUseCaseType =\n (typeof APPROVAL_USE_CASE_TYPES)[keyof typeof APPROVAL_USE_CASE_TYPES]\n\n/**\n * The shortest extension that accounts-backend accepts.\n *\n * `POST /:orgId/extendtrial` refuses a date that is less than 14 days away\n * when the body carries `onBehalfOf`, and it measures those 14 days when the\n * CALL arrives. The floor here is 15 and not 14, for two reasons:\n *\n * - The two clocks are not the same instant. Accounts builds its own \"14 days\n * from now\" a few milliseconds after control-backend builds the date, so a\n * date of exactly 14 days is already in the past for the comparison.\n * - Accounts counts with `setDate`, which is calendar arithmetic. A change of\n * the daylight saving time moves its limit by an hour.\n */\nexport const EXTEND_TRIAL_MIN_DAYS = 15\n\n/**\n * The longest extension that accounts-backend accepts.\n *\n * The same route refuses a date more than 62 days away. 61 keeps the same\n * margin as the floor above.\n */\nexport const EXTEND_TRIAL_MAX_DAYS = 61\n\n/**\n * The most roles that one change-platform-roles request adds, or removes.\n *\n * The form of control-frontend offers every platform role, and a person who\n * gives a Camundi a new position rarely changes more than a few. A request with\n * more roles than this is a request that nobody reads before they approve it.\n */\nexport const PLATFORM_ROLE_CHANGE_MAX_ROLES = 10\n\n/**\n * The longest text value that one change-org-limits request writes.\n *\n * A text limit holds a short value, such as a list of regions. The bound keeps\n * a request readable for the person who approves it.\n */\nexport const ORG_LIMIT_TEXT_MAX_LENGTH = 2000\n\n/**\n * The most users that one purge-users request deletes.\n *\n * The God Mode purge deletes one user after the other, and Auth0 limits how\n * fast that goes. A list longer than this is also a list that no approver\n * reads before they agree.\n */\nexport const PURGE_USERS_MAX_USERS = 1000\n\n/**\n * What a person must hold to open or to approve a purge-users request.\n *\n * The purge route of accounts, `DELETE /api/users/:userId/purge`, needs both\n * of the caller. The approval route of accounts checks both of the person who\n * asked, when control-backend sends `purge: true`. The use case of\n * control-backend returns this list from `permissionsFor`, and the form passes\n * it to `canCreate`, so the button and the server never disagree.\n */\nexport const PURGE_USERS_REQUIRED_PERMISSIONS: readonly string[] = [\n AccountsPermissions.GOD_PURGE_USERS,\n AccountsPermissions.DELETE_USER,\n]\n\n/**\n * The largest metadata object that one update-user-metadata request writes,\n * in characters of its JSON text.\n *\n * This is a bound of the product, and not the limit of Auth0, which is far\n * larger. The approvers read the whole object before they agree, and an\n * object that is longer than this is an object that nobody reads.\n */\nexport const USER_METADATA_MAX_LENGTH = 16_000\n\n/**\n * The largest metadata object that a request carries as the object that the\n * person who asked READ.\n *\n * It is larger than `USER_METADATA_MAX_LENGTH`, so a user whose metadata grew\n * past that bound can still get an edit that makes it smaller. It still has a\n * bound, because the request keeps the copy in its row.\n */\nexport const USER_METADATA_EXPECTED_MAX_LENGTH = 500_000\n\n/**\n * The bounds of an automated-enterprise-onboarding request.\n *\n * The accounts wizard holds the first four, and the schema of control-backend\n * and the form of control-frontend read them here, so the form never sends\n * what the service refuses.\n */\nexport const ENTERPRISE_ONBOARDING_NAME_MAX_LENGTH = 50\nexport const ENTERPRISE_ONBOARDING_SFDC_ID_MAX_LENGTH = 50\nexport const ENTERPRISE_ONBOARDING_TERM_MIN_MONTHS = 1\nexport const ENTERPRISE_ONBOARDING_TERM_MAX_MONTHS = 60\n/** The most of one value metric that a contract includes. */\nexport const ENTERPRISE_ONBOARDING_METRIC_MAX = 1_000_000_000_000\n/**\n * The most clusters of one plan type that an onboarding reserves: the cap of\n * the accounts wizard. Accounts checks the limit of the plan only when it\n * reserves, after it created the two organizations, so the request must not\n * carry a count that the plan refuses.\n */\nexport const ENTERPRISE_ONBOARDING_RESERVATION_MAX = 20\n\n/**\n * Whether a string is a calendar day, `YYYY-MM-DD`, that exists.\n *\n * The shape alone is not enough: `Date` rolls `2026-02-30` to March 2 with\n * no error, and `2026-13-45` becomes an invalid date that a database refuses\n * only at the write. A request carries the day for days before it runs, so\n * the check is at the open, and in the form.\n */\nexport const isCalendarDay = (value: string): boolean =>\n /^\\d{4}-\\d{2}-\\d{2}$/.test(value) &&\n !Number.isNaN(Date.parse(value)) &&\n new Date(value).toISOString().startsWith(value)\n\n/** The longest email address that a change-user-email request carries. */\nexport const APPROVAL_EMAIL_MAX_LENGTH = 320\n\n/**\n * The email addresses that a change-user-email request accepts.\n *\n * It is the email pattern of zod 4.6. The pattern is here, so the schema of\n * control-backend and the form of control-frontend accept the same addresses,\n * and a form never sends an address that the service refuses.\n */\nexport const APPROVAL_EMAIL_PATTERN =\n // biome-ignore lint/complexity/noUselessEscapeInRegex: the same source as zod, character for character\n /^(?:[A-Za-z0-9_'+\\-]+\\.)*[A-Za-z0-9_'+\\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$/\n\n/**\n * The most generations that one change-generation-versions request changes.\n *\n * Each generation restarts every cluster on it. A request over more\n * generations than this is a request that no approver reads to the end.\n *\n * The two bounds of this use case also keep a request under the body limit\n * of control-backend (1 MiB, `server-options.ts` of the shared Fastify\n * layer): 20 generations, each with the expected value of the 2 image\n * versions of 2000 characters, is 80 000 characters before the rest of the\n * payload.\n */\nexport const GENERATION_VERSIONS_MAX_GENERATIONS = 20\n\n/**\n * The longest value of one mutable version of a generation.\n *\n * An image reference is short, and only the two image versions go through\n * the use case (`GENERATION_IMAGE_VERSION_KEYS`). The bound keeps a request\n * readable for the approver. See `GENERATION_VERSIONS_MAX_GENERATIONS` for\n * the sum.\n */\nexport const GENERATION_VERSION_VALUE_MAX_LENGTH = 2000\n"],
5
- "mappings": "AAMA,SAAS,2BAA2B;AAkB7B,MAAM,0BAA0B;AAAA,EACrC,oBAAoB;AAAA,EACpB,aAAa;AAAA,EACb,gBAAgB;AAAA;AAAA;AAAA;AAAA,EAIhB,6BAA6B;AAAA,EAC7B,uBAAuB;AAAA,EACvB,2BAA2B;AAAA,EAC3B,wBAAwB;AAAA,EACxB,qBAAqB;AAAA,EACrB,uBAAuB;AAAA;AAAA,EAEvB,qBAAqB;AAAA,EACrB,sBAAsB;AAAA,EACtB,iBAAiB;AAAA,EACjB,qBAAqB;AAAA,EACrB,iBAAiB;AAAA;AAAA,EAEjB,sBAAsB;AAAA;AAAA,EAEtB,oBAAoB;AAAA,EACpB,YAAY;AAAA,EACZ,iBAAiB;AAAA,EACjB,YAAY;AAAA,EACZ,oBAAoB;AAAA;AAAA;AAAA,EAGpB,mBAAmB;AAAA;AAAA;AAAA,EAGnB,qBAAqB;AAAA,EACrB,sBAAsB;AAAA;AAAA,EAEtB,4BAA4B;AAAA;AAAA;AAAA,EAG5B,4BAA4B;AAAA,EAC5B,4BAA4B;AAAA;AAAA;AAAA;AAAA,EAI5B,0BAA0B;AAAA;AAAA;AAAA;AAAA,EAI1B,gBAAgB;AAAA;AAAA;AAAA;AAAA,EAIhB,+BAA+B;AAAA;AAAA;AAAA,EAG/B,wBAAwB;AAAA,EACxB,wBAAwB;AAAA;AAAA;AAAA,EAGxB,gCAAgC;AAAA;AAAA;AAAA;AAAA,EAIhC,qBAAqB;AAAA;AAAA;AAAA;AAAA;AAAA,EAKrB,uBAAuB;AAAA;AAAA;AAAA;AAAA,EAIvB,oBAAoB;AAAA;AAAA;AAAA;AAAA,EAIpB,sBAAsB;AAAA;AAAA;AAAA;AAAA,EAItB,4BAA4B;AAAA;AAAA;AAAA,EAG5B,iBAAiB;AACnB;AAUO,MAAM,6BAA6B;AAUnC,MAAM,0CAA0C;AAGhD,MAAM,wCAAwC;AAC9C,MAAM,wCAAwC;AAkB9C,MAAM,wBAAwB;AAQ9B,MAAM,wBAAwB;AAS9B,MAAM,iCAAiC;AAQvC,MAAM,4BAA4B;AASlC,MAAM,wBAAwB;AAW9B,MAAM,mCAAsD;AAAA,EACjE,oBAAoB;AAAA,EACpB,oBAAoB;AACtB;AAUO,MAAM,2BAA2B;AAUjC,MAAM,oCAAoC;AAS1C,MAAM,wCAAwC;AAC9C,MAAM,2CAA2C;AACjD,MAAM,wCAAwC;AAC9C,MAAM,wCAAwC;AAE9C,MAAM,mCAAmC;AAOzC,MAAM,wCAAwC;AAU9C,MAAM,gBAAgB,CAAC,UAC5B,sBAAsB,KAAK,KAAK,KAChC,CAAC,OAAO,MAAM,KAAK,MAAM,KAAK,CAAC,KAC/B,IAAI,KAAK,KAAK,EAAE,YAAY,EAAE,WAAW,KAAK;AAGzC,MAAM,4BAA4B;AASlC,MAAM;AAAA;AAAA,EAEX;AAAA;AAcK,MAAM,sCAAsC;AAU5C,MAAM,sCAAsC;",
4
+ "sourcesContent": ["/*\n * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH\n * under one or more contributor license agreements. Licensed under a commercial license.\n * You may not use this file except in compliance with the commercial license.\n */\n\nimport { AccountsPermissions } from '../permissions/accounts.permissions.js'\n\n/**\n * The names and the value ranges of the registered use cases.\n *\n * control-backend owns the registry, and a frontend reads it at run time\n * (`GET /api/approvals/use-cases`). Two things cannot wait for that answer:\n *\n * - **The identifier**, because a caller must NAME a use case to find it in the\n * registry, and to open a request of it.\n * - **The range of a payload field**, because a form holds the value before any\n * call goes out.\n *\n * Both live here, so the schema of the service and the form of the browser can\n * never hold different numbers.\n */\n\n/** The identifier of each use case, on the wire and in the table. */\nexport const APPROVAL_USE_CASE_TYPES = {\n sendToExpiredTrial: 'send-to-expired-trial',\n extendTrial: 'extend-trial',\n moveClusterOrg: 'move-cluster-org',\n // One use case for each type of a cluster bulk operation. The types need\n // different permissions and different approval counts, and a use case holds\n // one of each. See `cluster-bulk-operation.ts` beside this file.\n clusterBulkGenerationUpdate: 'cluster-bulk-generation-update',\n clusterBulkPlanUpdate: 'cluster-bulk-plan-update',\n clusterBulkPlanTypeUpdate: 'cluster-bulk-plan-type-update',\n clusterBulkSleepWakeup: 'cluster-bulk-sleep-wakeup',\n clusterBulkDeletion: 'cluster-bulk-deletion',\n clusterBulkSpecUpdate: 'cluster-bulk-spec-update',\n // The second group, behind `control.camundiApprovalFlow`.\n changePlatformRoles: 'change-platform-roles',\n deleteInternalBackup: 'delete-internal-backup',\n changeOrgLimits: 'change-org-limits',\n deleteSsoConnection: 'delete-sso-connection',\n changeSalesPlan: 'change-sales-plan',\n llmBudgetTopUp: 'llm-budget-top-up',\n requestInNegotiation: 'request-in-negotiation',\n // The third group, behind `control.camundiApprovalFlow`.\n deleteOrganization: 'delete-organization',\n deleteUser: 'delete-user',\n changeUserEmail: 'change-user-email',\n purgeUsers: 'purge-users',\n updateUserMetadata: 'update-user-metadata',\n // A change of the feature flag table, on a stage where\n // `isFeatureFlagApprovalRequired` is true.\n changeFeatureFlag: 'change-feature-flag',\n // The publish and the rollback of a release-train generation. See\n // `release-train.ts` beside this file.\n releaseTrainPublish: 'release-train-publish',\n releaseTrainRollback: 'release-train-rollback',\n // Open to every Camundi, and it needs no approval on any stage.\n createPersonalOrganisation: 'create-personal-organisation',\n // Open to every Camundi, runs on dev only, and needs no approval. The two\n // use cases of custom hardware sizing.\n createCustomHardwareSizing: 'create-custom-hardware-sizing',\n removeCustomHardwareSizing: 'remove-custom-hardware-sizing',\n // The mutable versions of one or more generations, behind\n // `control.camundiApprovalFlow`. Console restarts every cluster of\n // each generation, which is why the write needs a second Camundi.\n changeGenerationVersions: 'change-generation-versions',\n // The name and the env vars of one generation, behind\n // `control.camundiApprovalFlow`. Console sends a new spec to every cluster of\n // the generation, which is why the write needs a second Camundi.\n editGeneration: 'edit-generation',\n // A new enterprise customer: a production and a development organization\n // in one step, behind `control.camundiApprovalFlow`. See\n // `automated-enterprise-onboarding.ts` in control-backend.\n automatedEnterpriseOnboarding: 'automated-enterprise-onboarding',\n // Open to every Camundi, runs on dev only, and needs no approval. The two\n // use cases of custom generations.\n createCustomGeneration: 'create-custom-generation',\n removeCustomGeneration: 'remove-custom-generation',\n // Open to org owners, runs on dev only, and needs no approval. It applies\n // or deactivates a cluster modification config of one cluster.\n selfServiceClusterModification: 'self-service-cluster-modification',\n // The enterprise onboarding of one organization, behind\n // `control.camundiApprovalFlow`: the contract, the plan type of\n // each cluster, and the reservations, in one request.\n onboardToEnterprise: 'onboard-to-enterprise',\n // The plan type and the hardware packages of ONE cluster, behind\n // `control.camundiApprovalFlow`. The bulk plan type update beside it also\n // checks the reservations and moves the cluster plan, but each cluster\n // keeps its packages there. This one can change the packages, and it has a\n // stale guard. See `change-cluster-plan-type.ts` in control-backend.\n changeClusterPlanType: 'change-cluster-plan-type',\n // The deletion of one cluster whose TTL ran out. Only the delete cron of\n // console-backend opens it, and it needs no approval. It is a record of the\n // deletion. See `cluster-ttl-deletion.ts` in control-backend.\n clusterTtlDeletion: 'cluster-ttl-deletion',\n // The deletion of the DB corpses or of the Kubernetes corpses that console\n // finds, behind `control.camundiApprovalFlow`. See\n // `delete-cluster-corpses.ts` in control-backend.\n deleteClusterCorpses: 'delete-cluster-corpses',\n // The force delete of a cluster plan type and of every cluster on it,\n // behind `control.camundiApprovalFlow`. See\n // `force-delete-cluster-plan-type.ts` in control-backend.\n forceDeleteClusterPlanType: 'force-delete-cluster-plan-type',\n // The deletion of one Console API client of an organization. See\n // `delete-api-client.ts` in control-backend.\n deleteApiClient: 'delete-api-client',\n // A time-limited grant that lets one Camundi run a pending request with no\n // approval, and the scheduled request that ends it. Behind\n // `control.approvalOverride`. See `approval-override.ts` in control-backend.\n grantApprovalOverride: 'grant-approval-override',\n revokeApprovalOverride: 'revoke-approval-override',\n} as const\n\n/** The longest approval override grant, in hours: 14 days. */\nexport const APPROVAL_OVERRIDE_MAX_HOURS = 14 * 24\n\n/** The duration that the form of control-frontend offers first: one day. */\nexport const APPROVAL_OVERRIDE_DEFAULT_HOURS = 24\n\n/**\n * The use cases that an approval override grant can NOT run.\n *\n * Every other use case is overridable. These change who holds a permission,\n * the override mechanism itself, the feature flags of a stage, the address\n * that a person signs in with, or delete customer data, a backup, a sign-in\n * path, or a whole fleet of clusters. None of them is an emergency that one\n * Camundi must fix alone.\n *\n * The list fails open: a use case that is not here IS overridable. Thus a new\n * use case that deletes or that changes access must be added here. Its route\n * in accounts-backend or console-backend then refuses `overriddenBy` by\n * itself: `approvalActorSchemaFor` of their `AdminApprovalsRouter.ts` reads\n * this list.\n */\nexport const APPROVAL_OVERRIDE_DENYLIST: readonly string[] = [\n APPROVAL_USE_CASE_TYPES.changePlatformRoles,\n APPROVAL_USE_CASE_TYPES.grantApprovalOverride,\n APPROVAL_USE_CASE_TYPES.revokeApprovalOverride,\n APPROVAL_USE_CASE_TYPES.changeFeatureFlag,\n APPROVAL_USE_CASE_TYPES.purgeUsers,\n APPROVAL_USE_CASE_TYPES.deleteUser,\n APPROVAL_USE_CASE_TYPES.deleteOrganization,\n APPROVAL_USE_CASE_TYPES.clusterBulkDeletion,\n APPROVAL_USE_CASE_TYPES.forceDeleteClusterPlanType,\n APPROVAL_USE_CASE_TYPES.releaseTrainPublish,\n APPROVAL_USE_CASE_TYPES.releaseTrainRollback,\n APPROVAL_USE_CASE_TYPES.deleteSsoConnection,\n APPROVAL_USE_CASE_TYPES.changeUserEmail,\n APPROVAL_USE_CASE_TYPES.deleteInternalBackup,\n APPROVAL_USE_CASE_TYPES.deleteApiClient,\n]\n\n/** isApprovalOverridable reports whether a grant can run a request of this type. */\nexport const isApprovalOverridable = (type: string): boolean =>\n !APPROVAL_OVERRIDE_DENYLIST.includes(type)\n\n/**\n * The most personal organizations that one Camundi can request.\n *\n * The request runs with no approval, so this cap is the only guard against a\n * loop that creates organizations without end. It counts the requests that a\n * Camundi opened, and a person who needs more asks a Camundi who holds\n * `AccountsPermissions.CREATE_ORG`.\n */\nexport const PERSONAL_ORGANIZATION_MAX_PER_REQUESTER = 10\n\n/** The shortest and longest name of a personal organization. */\nexport const PERSONAL_ORGANIZATION_NAME_MIN_LENGTH = 3\nexport const PERSONAL_ORGANIZATION_NAME_MAX_LENGTH = 100\n\nexport type ApprovalUseCaseType =\n (typeof APPROVAL_USE_CASE_TYPES)[keyof typeof APPROVAL_USE_CASE_TYPES]\n\n/**\n * The shortest extension that accounts-backend accepts.\n *\n * `POST /:orgId/extendtrial` refuses a date that is less than 14 days away\n * when the body carries `onBehalfOf`, and it measures those 14 days when the\n * CALL arrives. The floor here is 15 and not 14, for two reasons:\n *\n * - The two clocks are not the same instant. Accounts builds its own \"14 days\n * from now\" a few milliseconds after control-backend builds the date, so a\n * date of exactly 14 days is already in the past for the comparison.\n * - Accounts counts with `setDate`, which is calendar arithmetic. A change of\n * the daylight saving time moves its limit by an hour.\n */\nexport const EXTEND_TRIAL_MIN_DAYS = 15\n\n/**\n * The longest extension that accounts-backend accepts.\n *\n * The same route refuses a date more than 62 days away. 61 keeps the same\n * margin as the floor above.\n */\nexport const EXTEND_TRIAL_MAX_DAYS = 61\n\n/**\n * The most roles that one change-platform-roles request adds, or removes.\n *\n * The form of control-frontend offers every platform role, and a person who\n * gives a Camundi a new position rarely changes more than a few. A request with\n * more roles than this is a request that nobody reads before they approve it.\n */\nexport const PLATFORM_ROLE_CHANGE_MAX_ROLES = 10\n\n/**\n * The longest text value that one change-org-limits request writes.\n *\n * A text limit holds a short value, such as a list of regions. The bound keeps\n * a request readable for the person who approves it.\n */\nexport const ORG_LIMIT_TEXT_MAX_LENGTH = 2000\n\n/**\n * LLM_BUDGET_TOP_UP_THRESHOLD is the share of the budget that the spend of a\n * managed LLM key must reach before a top-up is the normal case.\n *\n * A request below it is still possible, but the person who asks must confirm\n * it, and the request says so to the approver. Accounts refuses a top-up\n * below it that does not carry that confirmation.\n */\nexport const LLM_BUDGET_TOP_UP_THRESHOLD = 0.9\n\n/**\n * isLlmSpendBelowTopUpThreshold reports whether a top-up of a managed LLM key\n * needs the explicit confirmation of `LLM_BUDGET_TOP_UP_THRESHOLD`.\n */\nexport const isLlmSpendBelowTopUpThreshold = (\n spend: number,\n maxBudget: number,\n): boolean => spend < maxBudget * LLM_BUDGET_TOP_UP_THRESHOLD\n\n/**\n * The most users that one purge-users request deletes.\n *\n * The God Mode purge deletes one user after the other, and Auth0 limits how\n * fast that goes. A list longer than this is also a list that no approver\n * reads before they agree.\n */\nexport const PURGE_USERS_MAX_USERS = 1000\n\n/**\n * What a person must hold to open or to approve a purge-users request.\n *\n * The purge route of accounts, `DELETE /api/users/:userId/purge`, needs both\n * of the caller. The approval route of accounts checks both of the person who\n * asked, when control-backend sends `purge: true`. The use case of\n * control-backend returns this list from `permissionsFor`, and the form passes\n * it to `canCreate`, so the button and the server never disagree.\n */\nexport const PURGE_USERS_REQUIRED_PERMISSIONS: readonly string[] = [\n AccountsPermissions.GOD_PURGE_USERS,\n AccountsPermissions.DELETE_USER,\n]\n\n/**\n * The largest metadata object that one update-user-metadata request writes,\n * in characters of its JSON text.\n *\n * This is a bound of the product, and not the limit of Auth0, which is far\n * larger. The approvers read the whole object before they agree, and an\n * object that is longer than this is an object that nobody reads.\n */\nexport const USER_METADATA_MAX_LENGTH = 16_000\n\n/**\n * The largest metadata object that a request carries as the object that the\n * person who asked READ.\n *\n * It is larger than `USER_METADATA_MAX_LENGTH`, so a user whose metadata grew\n * past that bound can still get an edit that makes it smaller. It still has a\n * bound, because the request keeps the copy in its row.\n */\nexport const USER_METADATA_EXPECTED_MAX_LENGTH = 500_000\n\n/**\n * The bounds of an automated-enterprise-onboarding request.\n *\n * The accounts wizard holds the first four, and the schema of control-backend\n * and the form of control-frontend read them here, so the form never sends\n * what the service refuses.\n */\nexport const ENTERPRISE_ONBOARDING_NAME_MAX_LENGTH = 50\nexport const ENTERPRISE_ONBOARDING_SFDC_ID_MAX_LENGTH = 50\nexport const ENTERPRISE_ONBOARDING_TERM_MIN_MONTHS = 1\nexport const ENTERPRISE_ONBOARDING_TERM_MAX_MONTHS = 60\n/** The most of one value metric that a contract includes. */\nexport const ENTERPRISE_ONBOARDING_METRIC_MAX = 1_000_000_000_000\n/**\n * The most clusters of one plan type that an onboarding reserves: the cap of\n * the accounts wizard. Accounts checks the limit of the plan only when it\n * reserves, after it created the two organizations, so the request must not\n * carry a count that the plan refuses.\n */\nexport const ENTERPRISE_ONBOARDING_RESERVATION_MAX = 20\n\n/**\n * Whether a string is a calendar day, `YYYY-MM-DD`, that exists.\n *\n * The shape alone is not enough: `Date` rolls `2026-02-30` to March 2 with\n * no error, and `2026-13-45` becomes an invalid date that a database refuses\n * only at the write. A request carries the day for days before it runs, so\n * the check is at the open, and in the form.\n */\nexport const isCalendarDay = (value: string): boolean =>\n /^\\d{4}-\\d{2}-\\d{2}$/.test(value) &&\n !Number.isNaN(Date.parse(value)) &&\n new Date(value).toISOString().startsWith(value)\n\n/** The longest email address that a change-user-email request carries. */\nexport const APPROVAL_EMAIL_MAX_LENGTH = 320\n\n/**\n * The email addresses that a change-user-email request accepts.\n *\n * It is the email pattern of zod 4.6. The pattern is here, so the schema of\n * control-backend and the form of control-frontend accept the same addresses,\n * and a form never sends an address that the service refuses.\n */\nexport const APPROVAL_EMAIL_PATTERN =\n // biome-ignore lint/complexity/noUselessEscapeInRegex: the same source as zod, character for character\n /^(?:[A-Za-z0-9_'+\\-]+\\.)*[A-Za-z0-9_'+\\-]*[A-Za-z0-9_+-]@(?:[A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$/\n\n/**\n * The most generations that one change-generation-versions request changes.\n *\n * Each generation restarts every cluster on it. A request over more\n * generations than this is a request that no approver reads to the end.\n *\n * The two bounds of this use case also keep a request under the body limit\n * of control-backend (1 MiB, `server-options.ts` of the shared Fastify\n * layer): 20 generations, each with the expected value of the 2 image\n * versions of 2000 characters, is 80 000 characters before the rest of the\n * payload.\n */\nexport const GENERATION_VERSIONS_MAX_GENERATIONS = 20\n\n/**\n * The longest value of one mutable version of a generation.\n *\n * An image reference is short, and only the two image versions go through\n * the use case (`GENERATION_IMAGE_VERSION_KEYS`). The bound keeps a request\n * readable for the approver. See `GENERATION_VERSIONS_MAX_GENERATIONS` for\n * the sum.\n */\nexport const GENERATION_VERSION_VALUE_MAX_LENGTH = 2000\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,8 +1,19 @@
1
+ /**
2
+ * The flag of the approval flow of Camunda Control for Camundis.
3
+ *
4
+ * Four places read it, always by the email of the Camundi. control-backend
5
+ * opens the create routes of the approval use cases only while it is on.
6
+ * control-frontend shows the approval entries only while it is on.
7
+ * accounts-backend and console-backend lock the direct routes only while it
8
+ * is on.
9
+ */
10
+ export declare const CAMUNDI_APPROVAL_FLOW_FLAG = "control.camundiApprovalFlow";
1
11
  /**
2
12
  * The code of the refusal of a locked direct route.
3
13
  *
4
- * While `control.lockDirectRoutes` is on, accounts-backend and console-backend
5
- * refuse a person on a route that Camunda Control runs through a request.
14
+ * While `control.camundiApprovalFlow` is on, accounts-backend and
15
+ * console-backend refuse a person on a route that Camunda Control runs
16
+ * through a request.
6
17
  * The answer is 403 with the message as a text body, as for each other error.
7
18
  * The headers that follow carry the code and the approval entry, so that a
8
19
  * client does not parse the text to find a refusal.
@@ -1,3 +1,4 @@
1
+ const CAMUNDI_APPROVAL_FLOW_FLAG = "control.camundiApprovalFlow";
1
2
  const DIRECT_ROUTE_LOCKED = "DIRECT_ROUTE_LOCKED";
2
3
  const ERROR_CODE_HEADER = "x-camunda-error-code";
3
4
  const APPROVAL_ENTRY_HEADER = "x-camunda-approval-entry";
@@ -11,6 +12,7 @@ const directRouteLockHeaders = (approvalEntry) => ({
11
12
  });
12
13
  export {
13
14
  APPROVAL_ENTRY_HEADER,
15
+ CAMUNDI_APPROVAL_FLOW_FLAG,
14
16
  DIRECT_ROUTE_LOCKED,
15
17
  DIRECT_ROUTE_LOCK_HEADERS,
16
18
  ERROR_CODE_HEADER,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/constants/direct-route-lock.const.ts"],
4
- "sourcesContent": ["/*\n * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH\n * under one or more contributor license agreements. Licensed under a commercial license.\n * You may not use this file except in compliance with the commercial license.\n */\n\n/**\n * The code of the refusal of a locked direct route.\n *\n * While `control.lockDirectRoutes` is on, accounts-backend and console-backend\n * refuse a person on a route that Camunda Control runs through a request.\n * The answer is 403 with the message as a text body, as for each other error.\n * The headers that follow carry the code and the approval entry, so that a\n * client does not parse the text to find a refusal.\n */\nexport const DIRECT_ROUTE_LOCKED = 'DIRECT_ROUTE_LOCKED'\n\n/** The response header that holds the code of an error. */\nexport const ERROR_CODE_HEADER = 'x-camunda-error-code'\n\n/**\n * The response header that holds the name of the approval entry in Camunda\n * Control. The value is URI-encoded, because Node refuses a header value with\n * a character outside Latin-1.\n */\nexport const APPROVAL_ENTRY_HEADER = 'x-camunda-approval-entry'\n\n/**\n * The `exposedHeaders` of a CORS configuration. Without them, a browser on\n * another origin does not give the headers to the frontend.\n */\nexport const DIRECT_ROUTE_LOCK_HEADERS = [\n ERROR_CODE_HEADER,\n APPROVAL_ENTRY_HEADER,\n]\n\n/** The headers of the refusal of a locked direct route. */\nexport const directRouteLockHeaders = (\n approvalEntry: string,\n): Record<string, string> => ({\n [ERROR_CODE_HEADER]: DIRECT_ROUTE_LOCKED,\n [APPROVAL_ENTRY_HEADER]: encodeURIComponent(approvalEntry),\n})\n"],
5
- "mappings": "AAeO,MAAM,sBAAsB;AAG5B,MAAM,oBAAoB;AAO1B,MAAM,wBAAwB;AAM9B,MAAM,4BAA4B;AAAA,EACvC;AAAA,EACA;AACF;AAGO,MAAM,yBAAyB,CACpC,mBAC4B;AAAA,EAC5B,CAAC,iBAAiB,GAAG;AAAA,EACrB,CAAC,qBAAqB,GAAG,mBAAmB,aAAa;AAC3D;",
4
+ "sourcesContent": ["/*\n * Copyright Camunda Services GmbH and/or licensed to Camunda Services GmbH\n * under one or more contributor license agreements. Licensed under a commercial license.\n * You may not use this file except in compliance with the commercial license.\n */\n\n/**\n * The flag of the approval flow of Camunda Control for Camundis.\n *\n * Four places read it, always by the email of the Camundi. control-backend\n * opens the create routes of the approval use cases only while it is on.\n * control-frontend shows the approval entries only while it is on.\n * accounts-backend and console-backend lock the direct routes only while it\n * is on.\n */\nexport const CAMUNDI_APPROVAL_FLOW_FLAG = 'control.camundiApprovalFlow'\n\n/**\n * The code of the refusal of a locked direct route.\n *\n * While `control.camundiApprovalFlow` is on, accounts-backend and\n * console-backend refuse a person on a route that Camunda Control runs\n * through a request.\n * The answer is 403 with the message as a text body, as for each other error.\n * The headers that follow carry the code and the approval entry, so that a\n * client does not parse the text to find a refusal.\n */\nexport const DIRECT_ROUTE_LOCKED = 'DIRECT_ROUTE_LOCKED'\n\n/** The response header that holds the code of an error. */\nexport const ERROR_CODE_HEADER = 'x-camunda-error-code'\n\n/**\n * The response header that holds the name of the approval entry in Camunda\n * Control. The value is URI-encoded, because Node refuses a header value with\n * a character outside Latin-1.\n */\nexport const APPROVAL_ENTRY_HEADER = 'x-camunda-approval-entry'\n\n/**\n * The `exposedHeaders` of a CORS configuration. Without them, a browser on\n * another origin does not give the headers to the frontend.\n */\nexport const DIRECT_ROUTE_LOCK_HEADERS = [\n ERROR_CODE_HEADER,\n APPROVAL_ENTRY_HEADER,\n]\n\n/** The headers of the refusal of a locked direct route. */\nexport const directRouteLockHeaders = (\n approvalEntry: string,\n): Record<string, string> => ({\n [ERROR_CODE_HEADER]: DIRECT_ROUTE_LOCKED,\n [APPROVAL_ENTRY_HEADER]: encodeURIComponent(approvalEntry),\n})\n"],
5
+ "mappings": "AAeO,MAAM,6BAA6B;AAYnC,MAAM,sBAAsB;AAG5B,MAAM,oBAAoB;AAO1B,MAAM,wBAAwB;AAM9B,MAAM,4BAA4B;AAAA,EACvC;AAAA,EACA;AACF;AAGO,MAAM,yBAAyB,CACpC,mBAC4B;AAAA,EAC5B,CAAC,iBAAiB,GAAG;AAAA,EACrB,CAAC,qBAAqB,GAAG,mBAAmB,aAAa;AAC3D;",
6
6
  "names": []
7
7
  }