@camunda/ccma-shared-types 3.0.0 → 4.0.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 (52) hide show
  1. package/dist/_tests/approvals/cluster-bulk-operation.spec.d.ts +1 -0
  2. package/dist/_tests/dtos/FeatureFlagOverride.dto.spec.d.ts +1 -0
  3. package/dist/annotations/policy.d.ts +98 -0
  4. package/dist/annotations/policy.js +61 -0
  5. package/dist/annotations/policy.js.map +7 -0
  6. package/dist/approvals/cluster-bulk-operation.d.ts +196 -0
  7. package/dist/approvals/cluster-bulk-operation.js +127 -0
  8. package/dist/approvals/cluster-bulk-operation.js.map +7 -0
  9. package/dist/approvals/policy.d.ts +45 -5
  10. package/dist/approvals/policy.js +38 -11
  11. package/dist/approvals/policy.js.map +2 -2
  12. package/dist/approvals/use-cases.const.d.ts +73 -0
  13. package/dist/approvals/use-cases.const.js +21 -2
  14. package/dist/approvals/use-cases.const.js.map +2 -2
  15. package/dist/config/camundi-permission-rules.js +24 -3
  16. package/dist/config/camundi-permission-rules.js.map +2 -2
  17. package/dist/constants/misc.const.d.ts +2 -0
  18. package/dist/constants/misc.const.js +4 -0
  19. package/dist/constants/misc.const.js.map +2 -2
  20. package/dist/dtos/Annotation.dto.d.ts +240 -0
  21. package/dist/dtos/Annotation.dto.js +47 -0
  22. package/dist/dtos/Annotation.dto.js.map +7 -0
  23. package/dist/dtos/ApprovalJob.dto.d.ts +152 -2
  24. package/dist/dtos/ApprovalJob.dto.js +22 -0
  25. package/dist/dtos/ApprovalJob.dto.js.map +2 -2
  26. package/dist/dtos/CommonCluster.dto.d.ts +6 -0
  27. package/dist/dtos/ControlV2.dto.d.ts +55 -3
  28. package/dist/dtos/FeatureFlagOverride.dto.d.ts +226 -0
  29. package/dist/dtos/FeatureFlagOverride.dto.js +38 -0
  30. package/dist/dtos/FeatureFlagOverride.dto.js.map +7 -0
  31. package/dist/dtos/FeatureFlags.dto.d.ts +58 -45
  32. package/dist/dtos/FeatureFlags.dto.js +10 -0
  33. package/dist/dtos/FeatureFlags.dto.js.map +3 -3
  34. package/dist/dtos/K8sContext.dto.d.ts +13 -4
  35. package/dist/index.d.ts +16 -8
  36. package/dist/index.js +113 -7
  37. package/dist/index.js.map +2 -2
  38. package/dist/permissions/accounts.permissions.d.ts +35 -0
  39. package/dist/permissions/accounts.permissions.js +33 -1
  40. package/dist/permissions/accounts.permissions.js.map +2 -2
  41. package/dist/permissions/combo.js +10 -2
  42. package/dist/permissions/combo.js.map +2 -2
  43. package/dist/permissions/console.permissions.d.ts +32 -0
  44. package/dist/permissions/console.permissions.js +24 -2
  45. package/dist/permissions/console.permissions.js.map +2 -2
  46. package/dist/types/Roles.d.ts +1 -1
  47. package/dist/types/Roles.js.map +2 -2
  48. package/package.json +1 -1
  49. package/dist/constants/failoverRegionPairs.const.d.ts +0 -36
  50. package/dist/constants/failoverRegionPairs.const.js +0 -34
  51. package/dist/constants/failoverRegionPairs.const.js.map +0 -7
  52. /package/dist/_tests/{constants/failoverRegionPairs.const.spec.d.ts → annotations/policy.spec.d.ts} +0 -0
@@ -1,4 +1,9 @@
1
1
  import { UNKNOWN_ACTOR_EMAIL } from "../constants/misc.const.js";
2
+ import { ConsolePermissions } from "../permissions/console.permissions.js";
3
+ function approvalPermissionCheck(held) {
4
+ const all = held.has(ConsolePermissions.APPROVALS_ALL_USE_CASES);
5
+ return (permission) => all || held.has(permission);
6
+ }
2
7
  function isSameActorEmail(one, other) {
3
8
  const left = comparableActorEmail(one);
4
9
  return left.length > 0 && left === comparableActorEmail(other);
@@ -7,6 +12,9 @@ function comparableActorEmail(value) {
7
12
  const trimmed = value?.trim().toLowerCase() ?? "";
8
13
  return trimmed === UNKNOWN_ACTOR_EMAIL ? "" : trimmed;
9
14
  }
15
+ function isSameActor(identity, actor) {
16
+ return identity.userId === actor.userId || isSameActorEmail(identity.email, actor.email);
17
+ }
10
18
  function hasActorEmail(actor) {
11
19
  return comparableActorEmail(actor.email).length > 0;
12
20
  }
@@ -33,20 +41,33 @@ function requiredPermissionsOf(job) {
33
41
  return job.requiredPermissions;
34
42
  }
35
43
  function canView(job, actor, useCase) {
36
- if (actor.has(useCase.requiredPermission) || job.createdBy.userId === actor.userId || isSameActorEmail(job.createdBy.email, actor.email)) {
44
+ if (actor.has(useCase.requiredPermission) || isSameActor(job.createdBy, actor)) {
37
45
  return ALLOWED;
38
46
  }
39
47
  return deny("forbidden", "You cannot see this request.");
40
48
  }
41
49
  function canCreate(actor, useCase, request = {}) {
42
50
  if (useCase.createPermission && !actor.has(useCase.createPermission)) {
43
- return deny("forbidden", "You cannot open this request.");
51
+ return deny(
52
+ "forbidden",
53
+ `Opening this request needs ${useCase.createPermission}, and you do not hold it.`
54
+ );
44
55
  }
45
56
  const raised = (request.requiredPermissions ?? []).filter(
46
57
  (permission) => permission !== useCase.requiredPermission
47
58
  );
48
59
  if (raised.length > 0 && !hasEveryPermission(actor, raised)) {
49
- return deny("forbidden", "You cannot open this request.");
60
+ return deny(
61
+ "forbidden",
62
+ `This payload needs ${raised.join(" and ")}, and you do not hold it.`
63
+ );
64
+ }
65
+ const toRun = request.requiredPermissions ?? [useCase.requiredPermission];
66
+ if (resolveRequiredAcks(useCase, request.requiredAcks) === 0 && !hasEveryPermission(actor, toRun)) {
67
+ return deny(
68
+ "forbidden",
69
+ `This request needs no approval, so opening it runs it. That needs ${toRun.join(" and ")}, and you do not hold it.`
70
+ );
50
71
  }
51
72
  if (useCase.creatorEmailRequired && !hasActorEmail(actor)) {
52
73
  return deny(
@@ -87,15 +108,13 @@ function canAcknowledge(job, actor, now) {
87
108
  `This request is ${job.status} and can no longer be approved.`
88
109
  );
89
110
  }
90
- if (job.createdBy.userId === actor.userId || isSameActorEmail(job.createdBy.email, actor.email)) {
111
+ if (isSameActor(job.createdBy, actor)) {
91
112
  return deny(
92
113
  "self-approval",
93
114
  "You opened this request, so you cannot approve it."
94
115
  );
95
116
  }
96
- if (job.acks.some(
97
- (ack) => ack.userId === actor.userId || isSameActorEmail(ack.email, actor.email)
98
- )) {
117
+ if (job.acks.some((ack) => isSameActor(ack, actor))) {
99
118
  return deny("already-acknowledged", "You already approved this request.");
100
119
  }
101
120
  return ALLOWED;
@@ -104,9 +123,6 @@ function canReject(job, actor, now, reason) {
104
123
  if (!hasEveryPermission(actor, job.requiredPermissions)) {
105
124
  return deny("forbidden", "You cannot reject this request.");
106
125
  }
107
- if (reason.trim().length === 0) {
108
- return deny("no-reason", "Give a reason to reject this request.");
109
- }
110
126
  if (isExpired(job, now)) {
111
127
  return deny(
112
128
  "expired",
@@ -119,10 +135,19 @@ function canReject(job, actor, now, reason) {
119
135
  `This request is ${job.status} and can no longer be rejected.`
120
136
  );
121
137
  }
138
+ if (isSameActor(job.createdBy, actor)) {
139
+ return deny(
140
+ "self-rejection",
141
+ "You opened this request, so withdraw it instead of rejecting it."
142
+ );
143
+ }
144
+ if (reason.trim().length === 0) {
145
+ return deny("no-reason", "Give a reason to reject this request.");
146
+ }
122
147
  return ALLOWED;
123
148
  }
124
149
  function canWithdraw(job, actor, now) {
125
- if (job.createdBy.userId !== actor.userId) {
150
+ if (!isSameActor(job.createdBy, actor)) {
126
151
  return deny(
127
152
  "not-creator",
128
153
  "Only the person who opened a request can withdraw it."
@@ -155,6 +180,7 @@ function canExecute(job, actor) {
155
180
  return ALLOWED;
156
181
  }
157
182
  export {
183
+ approvalPermissionCheck,
158
184
  canAcknowledge,
159
185
  canCreate,
160
186
  canExecute,
@@ -165,6 +191,7 @@ export {
165
191
  hasActorEmail,
166
192
  hasEveryPermission,
167
193
  isExpired,
194
+ isSameActor,
168
195
  isSameActorEmail,
169
196
  remainingAcks,
170
197
  requiredPermissionsOf,
@@ -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'\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 * 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 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 | '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/** Reports the status to show, which is `expired` once the time has passed. */\nexport function effectiveStatus(\n job: ApprovalJobDto,\n now: Date,\n): ApprovalJobDto['status'] {\n return isExpired(job, now) ? 'expired' : 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 job.createdBy.userId === actor.userId ||\n isSameActorEmail(job.createdBy.email, actor.email)\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 if (useCase.createPermission && !actor.has(useCase.createPermission)) {\n return deny('forbidden', 'You cannot open this request.')\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('forbidden', 'You cannot open this request.')\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 (\n job.createdBy.userId === actor.userId ||\n isSameActorEmail(job.createdBy.email, actor.email)\n ) {\n return deny(\n 'self-approval',\n 'You opened this request, so you cannot approve it.',\n )\n }\n if (\n job.acks.some(\n (ack) =>\n ack.userId === actor.userId || isSameActorEmail(ack.email, actor.email),\n )\n ) {\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 * The creator MAY reject their own request. That is a withdrawal with a reason,\n * and stopping it would only push the person to the withdraw button, where the\n * reason is lost.\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 (reason.trim().length === 0) {\n return deny('no-reason', 'Give a reason to 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 return ALLOWED\n}\n\n/** Only the person who opened a request can take it back. */\nexport function canWithdraw(\n job: ApprovalJobDto,\n actor: ApprovalActorView,\n now: Date,\n): ApprovalDecision {\n if (job.createdBy.userId !== actor.userId) {\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.\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): ApprovalDecision {\n if (!hasEveryPermission(actor, job.requiredPermissions)) {\n return deny('forbidden', 'You cannot run this 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 return ALLOWED\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 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/** Reports the status to show, which is `expired` once the time has passed. */\nexport function effectiveStatus(\n job: ApprovalJobDto,\n now: Date,\n): ApprovalJobDto['status'] {\n return isExpired(job, now) ? 'expired' : 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 already names a permission\n // the same way (\"Approving needs aacp\").\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 if (\n resolveRequiredAcks(useCase, request.requiredAcks) === 0 &&\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.\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): ApprovalDecision {\n if (!hasEveryPermission(actor, job.requiredPermissions)) {\n return deny('forbidden', 'You cannot run this 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 return ALLOWED\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -16,6 +16,14 @@
16
16
  export declare const APPROVAL_USE_CASE_TYPES: {
17
17
  readonly sendToExpiredTrial: "send-to-expired-trial";
18
18
  readonly extendTrial: "extend-trial";
19
+ readonly moveClusterOrg: "move-cluster-org";
20
+ readonly clusterBulkGenerationUpdate: "cluster-bulk-generation-update";
21
+ readonly clusterBulkPlanUpdate: "cluster-bulk-plan-update";
22
+ readonly clusterBulkPlanTypeUpdate: "cluster-bulk-plan-type-update";
23
+ readonly clusterBulkSleepWakeup: "cluster-bulk-sleep-wakeup";
24
+ readonly clusterBulkDeletion: "cluster-bulk-deletion";
25
+ readonly clusterBulkSpecUpdate: "cluster-bulk-spec-update";
26
+ readonly featureFlagOverride: "feature-flag-override";
19
27
  };
20
28
  export type ApprovalUseCaseType = (typeof APPROVAL_USE_CASE_TYPES)[keyof typeof APPROVAL_USE_CASE_TYPES];
21
29
  /**
@@ -39,3 +47,68 @@ export declare const EXTEND_TRIAL_MIN_DAYS = 15;
39
47
  * margin as the floor above.
40
48
  */
41
49
  export declare const EXTEND_TRIAL_MAX_DAYS = 61;
50
+ /**
51
+ * The shortest life of a feature flag override, in days.
52
+ *
53
+ * One day. An override that ends sooner than the next working day is a change
54
+ * that nobody can look at before it goes away again.
55
+ */
56
+ export declare const FLAG_OVERRIDE_MIN_DAYS = 1;
57
+ /**
58
+ * The longest life of a feature flag override, in days.
59
+ *
60
+ * An override is a temporary thing. Without a ceiling it becomes a second,
61
+ * invisible layer of configuration, and then nobody can answer "what runs on
62
+ * prod?" from the source alone. A change that has to hold for longer belongs
63
+ * in `defaults.ts`, where a reviewer reads it.
64
+ */
65
+ export declare const FLAG_OVERRIDE_MAX_DAYS = 90;
66
+ /**
67
+ * The life of an override when the person names no other number.
68
+ *
69
+ * Two weeks is long enough for a rollout to prove itself, and short enough
70
+ * that a forgotten override goes away by itself.
71
+ */
72
+ export declare const FLAG_OVERRIDE_DEFAULT_DAYS = 14;
73
+ /**
74
+ * Whether a stored override reaches any reader yet.
75
+ *
76
+ * It is `false` today, and the write path is closed at both ends: the page
77
+ * turns the Override button off, and the create route of control-backend
78
+ * answers 404. It lives HERE, and not in the page, because both sides read
79
+ * it. One switch cannot drift; a disabled button beside an open route is not
80
+ * a closed door, and that is what the review found.
81
+ *
82
+ * ## Why it is still `false`, and what is NOT true any more
83
+ *
84
+ * This said that control-backend reads exactly ONE flag, `control.approvals`,
85
+ * which takes no override, so the set of flags that this service reads and
86
+ * the set that take an override do not meet. **That stopped being true while
87
+ * this branch was open.** `main` added the annotations component, and
88
+ * `requireAnnotationsEnabled` reads `request.flags.control.annotations`:
89
+ *
90
+ * ```
91
+ * approvals.routes.ts:368 if (!request.flags.control.approvals) {
92
+ * annotations.routes.ts:313 if (!request.flags.control.annotations) {
93
+ * ```
94
+ *
95
+ * `control.annotations` is `overridable: true`, and `feature-flags.plugin.ts`
96
+ * applies the overrides in an application `preHandler`, which runs BEFORE a
97
+ * route-level one. So a STAGE override of that one flag would reach a real
98
+ * reader today.
99
+ *
100
+ * The intersection is therefore no longer empty: it holds one flag of 28, in
101
+ * one of the two scopes. That is the reason to keep this `false` rather than
102
+ * to turn it on. One boolean cannot say "this flag in this scope reaches a
103
+ * reader, and the other 27 do not": `true` would open a form that writes a
104
+ * row and moves nothing for every flag but one, which is the failure this
105
+ * constant exists to prevent, in the other direction.
106
+ *
107
+ * ## Phase 2
108
+ *
109
+ * Availability belongs per flag AND per scope, derived from which service
110
+ * reads the flag and whether that reader passes an `orgId` — not from one
111
+ * global switch. See section 8 of the design. Until then this stays `false`,
112
+ * which is safe: it is closed, and never wrongly open.
113
+ */
114
+ export declare const OVERRIDES_REACH_A_READER = false;
@@ -1,12 +1,31 @@
1
1
  const APPROVAL_USE_CASE_TYPES = {
2
2
  sendToExpiredTrial: "send-to-expired-trial",
3
- extendTrial: "extend-trial"
3
+ extendTrial: "extend-trial",
4
+ moveClusterOrg: "move-cluster-org",
5
+ // One use case for each type of a cluster bulk operation. The types need
6
+ // different permissions and different approval counts, and a use case holds
7
+ // one of each. See `cluster-bulk-operation.ts` beside this file.
8
+ clusterBulkGenerationUpdate: "cluster-bulk-generation-update",
9
+ clusterBulkPlanUpdate: "cluster-bulk-plan-update",
10
+ clusterBulkPlanTypeUpdate: "cluster-bulk-plan-type-update",
11
+ clusterBulkSleepWakeup: "cluster-bulk-sleep-wakeup",
12
+ clusterBulkDeletion: "cluster-bulk-deletion",
13
+ clusterBulkSpecUpdate: "cluster-bulk-spec-update",
14
+ featureFlagOverride: "feature-flag-override"
4
15
  };
5
16
  const EXTEND_TRIAL_MIN_DAYS = 15;
6
17
  const EXTEND_TRIAL_MAX_DAYS = 61;
18
+ const FLAG_OVERRIDE_MIN_DAYS = 1;
19
+ const FLAG_OVERRIDE_MAX_DAYS = 90;
20
+ const FLAG_OVERRIDE_DEFAULT_DAYS = 14;
21
+ const OVERRIDES_REACH_A_READER = false;
7
22
  export {
8
23
  APPROVAL_USE_CASE_TYPES,
9
24
  EXTEND_TRIAL_MAX_DAYS,
10
- EXTEND_TRIAL_MIN_DAYS
25
+ EXTEND_TRIAL_MIN_DAYS,
26
+ FLAG_OVERRIDE_DEFAULT_DAYS,
27
+ FLAG_OVERRIDE_MAX_DAYS,
28
+ FLAG_OVERRIDE_MIN_DAYS,
29
+ OVERRIDES_REACH_A_READER
11
30
  };
12
31
  //# 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\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} as const\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"],
5
- "mappings": "AAsBO,MAAM,0BAA0B;AAAA,EACrC,oBAAoB;AAAA,EACpB,aAAa;AACf;AAkBO,MAAM,wBAAwB;AAQ9B,MAAM,wBAAwB;",
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 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 featureFlagOverride: 'feature-flag-override',\n} as const\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 shortest life of a feature flag override, in days.\n *\n * One day. An override that ends sooner than the next working day is a change\n * that nobody can look at before it goes away again.\n */\nexport const FLAG_OVERRIDE_MIN_DAYS = 1\n\n/**\n * The longest life of a feature flag override, in days.\n *\n * An override is a temporary thing. Without a ceiling it becomes a second,\n * invisible layer of configuration, and then nobody can answer \"what runs on\n * prod?\" from the source alone. A change that has to hold for longer belongs\n * in `defaults.ts`, where a reviewer reads it.\n */\nexport const FLAG_OVERRIDE_MAX_DAYS = 90\n\n/**\n * The life of an override when the person names no other number.\n *\n * Two weeks is long enough for a rollout to prove itself, and short enough\n * that a forgotten override goes away by itself.\n */\nexport const FLAG_OVERRIDE_DEFAULT_DAYS = 14\n\n/**\n * Whether a stored override reaches any reader yet.\n *\n * It is `false` today, and the write path is closed at both ends: the page\n * turns the Override button off, and the create route of control-backend\n * answers 404. It lives HERE, and not in the page, because both sides read\n * it. One switch cannot drift; a disabled button beside an open route is not\n * a closed door, and that is what the review found.\n *\n * ## Why it is still `false`, and what is NOT true any more\n *\n * This said that control-backend reads exactly ONE flag, `control.approvals`,\n * which takes no override, so the set of flags that this service reads and\n * the set that take an override do not meet. **That stopped being true while\n * this branch was open.** `main` added the annotations component, and\n * `requireAnnotationsEnabled` reads `request.flags.control.annotations`:\n *\n * ```\n * approvals.routes.ts:368 if (!request.flags.control.approvals) {\n * annotations.routes.ts:313 if (!request.flags.control.annotations) {\n * ```\n *\n * `control.annotations` is `overridable: true`, and `feature-flags.plugin.ts`\n * applies the overrides in an application `preHandler`, which runs BEFORE a\n * route-level one. So a STAGE override of that one flag would reach a real\n * reader today.\n *\n * The intersection is therefore no longer empty: it holds one flag of 28, in\n * one of the two scopes. That is the reason to keep this `false` rather than\n * to turn it on. One boolean cannot say \"this flag in this scope reaches a\n * reader, and the other 27 do not\": `true` would open a form that writes a\n * row and moves nothing for every flag but one, which is the failure this\n * constant exists to prevent, in the other direction.\n *\n * ## Phase 2\n *\n * Availability belongs per flag AND per scope, derived from which service\n * reads the flag and whether that reader passes an `orgId` \u2014 not from one\n * global switch. See section 8 of the design. Until then this stays `false`,\n * which is safe: it is closed, and never wrongly open.\n */\nexport const OVERRIDES_REACH_A_READER = false\n"],
5
+ "mappings": "AAsBO,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,EACvB,qBAAqB;AACvB;AAkBO,MAAM,wBAAwB;AAQ9B,MAAM,wBAAwB;AAQ9B,MAAM,yBAAyB;AAU/B,MAAM,yBAAyB;AAQ/B,MAAM,6BAA6B;AA2CnC,MAAM,2BAA2B;",
6
6
  "names": []
7
7
  }
@@ -24,11 +24,32 @@ const CONFIG = {
24
24
  "support:agent"
25
25
  ],
26
26
  // Pre-Sales as per https://confluence.camunda.com/spaces/HAN/pages/168660217/Adjust+C8+SaaS+permissions+and+sales+plan#AdjustC8SaaSpermissionsandsalesplan-DetailsperRole
27
+ // Professional Services holds 36 people, the consultants. Six of them
28
+ // sit in the Education subdivision. They train customers and do not
29
+ // work on customer clusters.
30
+ //
31
+ // The augmenter can only ADD a role, so no rule here can EXCLUDE
32
+ // Education from this grant. The other 30 consultants carry no
33
+ // subdivision at all, so a subdivision rule cannot name them either.
34
+ //
35
+ // This grant covers the whole division on purpose. A rule that keys on
36
+ // the ABSENCE of a subdivision id is more fragile than one that keys on
37
+ // a present id, and two comments in this file already record a
38
+ // subdivision id that a HiBob restructure broke without warning.
39
+ //
40
+ // If the 30 consultants without a subdivision get one of their own one
41
+ // day, move this grant down to that subdivision and remove this rule.
42
+ // Education would then stop inheriting the grant, because the division
43
+ // no longer carries it and the subdivision rule never named Education.
44
+ "266189454": ["console:clusterMover"],
45
+ // Professional Services
27
46
  // Enterprise Success holds the CSMs and the TAMs. A customer who has
28
47
  // privacy concerns asks them to switch optional telemetry off, thus they
29
- // carry the capability. HiBob marks no TAM, thus the division is the
30
- // smallest group that holds all of them.
31
- "266229124": ["console:optionalTelemetryManager"]
48
+ // carry that capability. HiBob marks no TAM, thus the division is the
49
+ // smallest group that holds all of them. The CSMs and TAMs also move a
50
+ // cluster to another org for a customer, thus they carry
51
+ // `console:clusterMover` too.
52
+ "266229124": ["console:optionalTelemetryManager", "console:clusterMover"]
32
53
  // Enterprise Success
33
54
  },
34
55
  subdivisionRules: {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/config/camundi-permission-rules.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 type { CamundiPermissionRules } from './camundi-permission-rules.types.js'\n\nconst CONFIG: CamundiPermissionRules = {\n departmentRules: {\n '205882305': ['accounts:read', 'accounts:trialExtender'], // CS\n Marketing: ['accounts:read', 'accounts:trialExtender'],\n '205882307': ['accounts:read', 'accounts:trialExtender'], // revops\n Products: ['console:admin:readonly', 'accounts:read'],\n Executive: ['accounts:read'],\n '259633605': ['accounts:read', 'accounts:trialExtender'], // Sales\n },\n divisionRules: {\n Engineering: ['console:admin:readonly'],\n '259633608': [\n 'accounts:connections:create',\n 'accounts:connections:update',\n 'support:agent',\n ], // Support\n '259633611': [\n 'accounts:trialExtender',\n 'accounts:limitExtender',\n 'support:agent',\n ], // Pre-Sales as per https://confluence.camunda.com/spaces/HAN/pages/168660217/Adjust+C8+SaaS+permissions+and+sales+plan#AdjustC8SaaSpermissionsandsalesplan-DetailsperRole\n // Enterprise Success holds the CSMs and the TAMs. A customer who has\n // privacy concerns asks them to switch optional telemetry off, thus they\n // carry the capability. HiBob marks no TAM, thus the division is the\n // smallest group that holds all of them.\n '266229124': ['console:optionalTelemetryManager'], // Enterprise Success\n },\n subdivisionRules: {\n // The console team is the \"Control plane\" subdivision. The id was\n // `256909487` until a HiBob restructure, and the augmenter matches a\n // subdivision exactly and stays quiet on a miss, thus the rule granted\n // nothing to anybody and nobody saw it.\n '267516827': ['accounts:admin', 'console:admin', 'consoleTeam'], // Control plane\n 'Engineering Management': [\n 'accounts:addSafeRoles',\n 'accounts:orgCreator',\n 'console:C8ReleaseManager',\n ], // Engineering Management\n // Same story: the id `258049291` is gone. The subdivision carries the name\n // `Controller` now, and a name is a valid key \u2014 `Engineering Management`\n // above is one too.\n Controller: ['console:clusterPlanManager', 'console:operationsEngineer'],\n },\n}\nexport default CONFIG\n"],
5
- "mappings": "AAQA,MAAM,SAAiC;AAAA,EACrC,iBAAiB;AAAA,IACf,aAAa,CAAC,iBAAiB,wBAAwB;AAAA;AAAA,IACvD,WAAW,CAAC,iBAAiB,wBAAwB;AAAA,IACrD,aAAa,CAAC,iBAAiB,wBAAwB;AAAA;AAAA,IACvD,UAAU,CAAC,0BAA0B,eAAe;AAAA,IACpD,WAAW,CAAC,eAAe;AAAA,IAC3B,aAAa,CAAC,iBAAiB,wBAAwB;AAAA;AAAA,EACzD;AAAA,EACA,eAAe;AAAA,IACb,aAAa,CAAC,wBAAwB;AAAA,IACtC,aAAa;AAAA,MACX;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA;AAAA,IACA,aAAa;AAAA,MACX;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAKA,aAAa,CAAC,kCAAkC;AAAA;AAAA,EAClD;AAAA,EACA,kBAAkB;AAAA;AAAA;AAAA;AAAA;AAAA,IAKhB,aAAa,CAAC,kBAAkB,iBAAiB,aAAa;AAAA;AAAA,IAC9D,0BAA0B;AAAA,MACxB;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA;AAAA;AAAA;AAAA;AAAA,IAIA,YAAY,CAAC,8BAA8B,4BAA4B;AAAA,EACzE;AACF;AACA,IAAO,mCAAQ;",
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 type { CamundiPermissionRules } from './camundi-permission-rules.types.js'\n\nconst CONFIG: CamundiPermissionRules = {\n departmentRules: {\n '205882305': ['accounts:read', 'accounts:trialExtender'], // CS\n Marketing: ['accounts:read', 'accounts:trialExtender'],\n '205882307': ['accounts:read', 'accounts:trialExtender'], // revops\n Products: ['console:admin:readonly', 'accounts:read'],\n Executive: ['accounts:read'],\n '259633605': ['accounts:read', 'accounts:trialExtender'], // Sales\n },\n divisionRules: {\n Engineering: ['console:admin:readonly'],\n '259633608': [\n 'accounts:connections:create',\n 'accounts:connections:update',\n 'support:agent',\n ], // Support\n '259633611': [\n 'accounts:trialExtender',\n 'accounts:limitExtender',\n 'support:agent',\n ], // Pre-Sales as per https://confluence.camunda.com/spaces/HAN/pages/168660217/Adjust+C8+SaaS+permissions+and+sales+plan#AdjustC8SaaSpermissionsandsalesplan-DetailsperRole\n // Professional Services holds 36 people, the consultants. Six of them\n // sit in the Education subdivision. They train customers and do not\n // work on customer clusters.\n //\n // The augmenter can only ADD a role, so no rule here can EXCLUDE\n // Education from this grant. The other 30 consultants carry no\n // subdivision at all, so a subdivision rule cannot name them either.\n //\n // This grant covers the whole division on purpose. A rule that keys on\n // the ABSENCE of a subdivision id is more fragile than one that keys on\n // a present id, and two comments in this file already record a\n // subdivision id that a HiBob restructure broke without warning.\n //\n // If the 30 consultants without a subdivision get one of their own one\n // day, move this grant down to that subdivision and remove this rule.\n // Education would then stop inheriting the grant, because the division\n // no longer carries it and the subdivision rule never named Education.\n '266189454': ['console:clusterMover'], // Professional Services\n // Enterprise Success holds the CSMs and the TAMs. A customer who has\n // privacy concerns asks them to switch optional telemetry off, thus they\n // carry that capability. HiBob marks no TAM, thus the division is the\n // smallest group that holds all of them. The CSMs and TAMs also move a\n // cluster to another org for a customer, thus they carry\n // `console:clusterMover` too.\n '266229124': ['console:optionalTelemetryManager', 'console:clusterMover'], // Enterprise Success\n },\n subdivisionRules: {\n // The console team is the \"Control plane\" subdivision. The id was\n // `256909487` until a HiBob restructure, and the augmenter matches a\n // subdivision exactly and stays quiet on a miss, thus the rule granted\n // nothing to anybody and nobody saw it.\n '267516827': ['accounts:admin', 'console:admin', 'consoleTeam'], // Control plane\n 'Engineering Management': [\n 'accounts:addSafeRoles',\n 'accounts:orgCreator',\n 'console:C8ReleaseManager',\n ], // Engineering Management\n // Same story: the id `258049291` is gone. The subdivision carries the name\n // `Controller` now, and a name is a valid key \u2014 `Engineering Management`\n // above is one too.\n Controller: ['console:clusterPlanManager', 'console:operationsEngineer'],\n },\n}\nexport default CONFIG\n"],
5
+ "mappings": "AAQA,MAAM,SAAiC;AAAA,EACrC,iBAAiB;AAAA,IACf,aAAa,CAAC,iBAAiB,wBAAwB;AAAA;AAAA,IACvD,WAAW,CAAC,iBAAiB,wBAAwB;AAAA,IACrD,aAAa,CAAC,iBAAiB,wBAAwB;AAAA;AAAA,IACvD,UAAU,CAAC,0BAA0B,eAAe;AAAA,IACpD,WAAW,CAAC,eAAe;AAAA,IAC3B,aAAa,CAAC,iBAAiB,wBAAwB;AAAA;AAAA,EACzD;AAAA,EACA,eAAe;AAAA,IACb,aAAa,CAAC,wBAAwB;AAAA,IACtC,aAAa;AAAA,MACX;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA;AAAA,IACA,aAAa;AAAA,MACX;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAkBA,aAAa,CAAC,sBAAsB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAOpC,aAAa,CAAC,oCAAoC,sBAAsB;AAAA;AAAA,EAC1E;AAAA,EACA,kBAAkB;AAAA;AAAA;AAAA;AAAA;AAAA,IAKhB,aAAa,CAAC,kBAAkB,iBAAiB,aAAa;AAAA;AAAA,IAC9D,0BAA0B;AAAA,MACxB;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA;AAAA;AAAA;AAAA;AAAA,IAIA,YAAY,CAAC,8BAA8B,4BAA4B;AAAA,EACzE;AACF;AACA,IAAO,mCAAQ;",
6
6
  "names": []
7
7
  }
@@ -33,6 +33,8 @@ export declare const MINIMUM_EYEPAIRS: {
33
33
  CLUSTER_GENERATION_UPDATE: number;
34
34
  CLUSTER_PLAN_UPDATE: number;
35
35
  CLUSTER_PLAN_TYPE_UPDATE: number;
36
+ CLUSTER_SLEEP_WAKEUP: number;
37
+ CLUSTER_SPEC_UPDATE: number;
36
38
  CLUSTER_PLAN_MODIFICATION: number;
37
39
  CLUSTER_MODIFICATION: number;
38
40
  };
@@ -16,6 +16,10 @@ const MINIMUM_EYEPAIRS = {
16
16
  CLUSTER_GENERATION_UPDATE: 2,
17
17
  CLUSTER_PLAN_UPDATE: 2,
18
18
  CLUSTER_PLAN_TYPE_UPDATE: 2,
19
+ // These two had no name, and each reader fell back to
20
+ // `CLUSTER_PLAN_UPDATE`. The values are the same as that fallback.
21
+ CLUSTER_SLEEP_WAKEUP: 2,
22
+ CLUSTER_SPEC_UPDATE: 2,
19
23
  CLUSTER_PLAN_MODIFICATION: 1,
20
24
  CLUSTER_MODIFICATION: 1
21
25
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/constants/misc.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\nexport const CONSOLE_API_USER_ID = 'console-api'\n\n/**\n * The namespace of the custom claims that Auth0 adds to a token of this fleet.\n *\n * An Auth0 API access token carries no `email` claim of its own. The rules of\n * the tenant put the email of the person under this namespace instead, and\n * every reader of the fleet must look there. A service that reads a top-level\n * `email` claim gets nothing, and the failure is silent: the identity is still\n * valid, so the request continues with an empty email.\n */\nexport const CAMUNDA_CLAIMS_NAMESPACE = 'https://camunda.com/meta'\n\n/**\n * The email of an actor whose token carries no email.\n *\n * It is a value and not an empty string, because an empty string reads like a\n * field that nobody wrote. A record that says `unknown` says that the token had\n * no email, which is a different fact.\n *\n * A rule that compares two people MUST NOT treat two of these as one person.\n * `isSameActorEmail` in `approvals/policy.ts` holds that.\n */\nexport const UNKNOWN_ACTOR_EMAIL = 'unknown'\nexport const TEMPORARY_CLUSTER_NAME = 'Get Started'\nexport type Stage = 'local' | 'dev' | 'int' | 'prod'\nexport const HOTBACKUP_MIN_CREATE_PERIOD = 1000 * 60 * 60 * 0.25 // 15 minutes\n\nexport const SECRETS_MAX_LENGTH = 4096\nexport const FIRSTNAME_LASTNAME_MAX_LENGTH = 255\n\nexport const STARTING_PRICE_PER_MONTH_IN_EURO = 99\n\nexport const MAX_LENGTH_CLUSTER_NAME = 50\n\nexport const NAMES_OF_ENTERPRISE_SALESPLANS_WITH_SLEEPING_CLUSTERS = [\n 'CELP with sleeping clusters',\n 'Camunda Partner',\n]\n\nexport const MINIMUM_EYEPAIRS = {\n CLUSTER_DELETION: 3,\n CLUSTER_GENERATION_UPDATE: 2,\n CLUSTER_PLAN_UPDATE: 2,\n CLUSTER_PLAN_TYPE_UPDATE: 2,\n CLUSTER_PLAN_MODIFICATION: 1,\n CLUSTER_MODIFICATION: 1,\n}\n\nexport const MAX_HARDWARE_PACKAGE_COUNT = 4\n"],
5
- "mappings": "AAMO,MAAM,sBAAsB;AAW5B,MAAM,2BAA2B;AAYjC,MAAM,sBAAsB;AAC5B,MAAM,yBAAyB;AAE/B,MAAM,8BAA8B,MAAO,KAAK,KAAK;AAErD,MAAM,qBAAqB;AAC3B,MAAM,gCAAgC;AAEtC,MAAM,mCAAmC;AAEzC,MAAM,0BAA0B;AAEhC,MAAM,wDAAwD;AAAA,EACnE;AAAA,EACA;AACF;AAEO,MAAM,mBAAmB;AAAA,EAC9B,kBAAkB;AAAA,EAClB,2BAA2B;AAAA,EAC3B,qBAAqB;AAAA,EACrB,0BAA0B;AAAA,EAC1B,2BAA2B;AAAA,EAC3B,sBAAsB;AACxB;AAEO,MAAM,6BAA6B;",
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\nexport const CONSOLE_API_USER_ID = 'console-api'\n\n/**\n * The namespace of the custom claims that Auth0 adds to a token of this fleet.\n *\n * An Auth0 API access token carries no `email` claim of its own. The rules of\n * the tenant put the email of the person under this namespace instead, and\n * every reader of the fleet must look there. A service that reads a top-level\n * `email` claim gets nothing, and the failure is silent: the identity is still\n * valid, so the request continues with an empty email.\n */\nexport const CAMUNDA_CLAIMS_NAMESPACE = 'https://camunda.com/meta'\n\n/**\n * The email of an actor whose token carries no email.\n *\n * It is a value and not an empty string, because an empty string reads like a\n * field that nobody wrote. A record that says `unknown` says that the token had\n * no email, which is a different fact.\n *\n * A rule that compares two people MUST NOT treat two of these as one person.\n * `isSameActorEmail` in `approvals/policy.ts` holds that.\n */\nexport const UNKNOWN_ACTOR_EMAIL = 'unknown'\nexport const TEMPORARY_CLUSTER_NAME = 'Get Started'\nexport type Stage = 'local' | 'dev' | 'int' | 'prod'\nexport const HOTBACKUP_MIN_CREATE_PERIOD = 1000 * 60 * 60 * 0.25 // 15 minutes\n\nexport const SECRETS_MAX_LENGTH = 4096\nexport const FIRSTNAME_LASTNAME_MAX_LENGTH = 255\n\nexport const STARTING_PRICE_PER_MONTH_IN_EURO = 99\n\nexport const MAX_LENGTH_CLUSTER_NAME = 50\n\nexport const NAMES_OF_ENTERPRISE_SALESPLANS_WITH_SLEEPING_CLUSTERS = [\n 'CELP with sleeping clusters',\n 'Camunda Partner',\n]\n\nexport const MINIMUM_EYEPAIRS = {\n CLUSTER_DELETION: 3,\n CLUSTER_GENERATION_UPDATE: 2,\n CLUSTER_PLAN_UPDATE: 2,\n CLUSTER_PLAN_TYPE_UPDATE: 2,\n // These two had no name, and each reader fell back to\n // `CLUSTER_PLAN_UPDATE`. The values are the same as that fallback.\n CLUSTER_SLEEP_WAKEUP: 2,\n CLUSTER_SPEC_UPDATE: 2,\n CLUSTER_PLAN_MODIFICATION: 1,\n CLUSTER_MODIFICATION: 1,\n}\n\nexport const MAX_HARDWARE_PACKAGE_COUNT = 4\n"],
5
+ "mappings": "AAMO,MAAM,sBAAsB;AAW5B,MAAM,2BAA2B;AAYjC,MAAM,sBAAsB;AAC5B,MAAM,yBAAyB;AAE/B,MAAM,8BAA8B,MAAO,KAAK,KAAK;AAErD,MAAM,qBAAqB;AAC3B,MAAM,gCAAgC;AAEtC,MAAM,mCAAmC;AAEzC,MAAM,0BAA0B;AAEhC,MAAM,wDAAwD;AAAA,EACnE;AAAA,EACA;AACF;AAEO,MAAM,mBAAmB;AAAA,EAC9B,kBAAkB;AAAA,EAClB,2BAA2B;AAAA,EAC3B,qBAAqB;AAAA,EACrB,0BAA0B;AAAA;AAAA;AAAA,EAG1B,sBAAsB;AAAA,EACtB,qBAAqB;AAAA,EACrB,2BAA2B;AAAA,EAC3B,sBAAsB;AACxB;AAEO,MAAM,6BAA6B;",
6
6
  "names": []
7
7
  }