@things-factory/auth-base 10.1.31 → 10.1.33

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 (108) hide show
  1. package/dist-server/controllers/invitation.d.ts +47 -10
  2. package/dist-server/controllers/invitation.js +159 -113
  3. package/dist-server/controllers/invitation.js.map +1 -1
  4. package/dist-server/controllers/profile.d.ts +1 -0
  5. package/dist-server/router/auth-public-process-router.js +36 -13
  6. package/dist-server/router/auth-public-process-router.js.map +1 -1
  7. package/dist-server/router/oauth2/oauth2-router.js +7 -2
  8. package/dist-server/router/oauth2/oauth2-router.js.map +1 -1
  9. package/dist-server/router/oauth2/oauth2-server.js +4 -4
  10. package/dist-server/router/oauth2/oauth2-server.js.map +1 -1
  11. package/dist-server/service/app-binding/app-binding-mutation.d.ts +27 -0
  12. package/dist-server/service/app-binding/app-binding-mutation.js +39 -6
  13. package/dist-server/service/app-binding/app-binding-mutation.js.map +1 -1
  14. package/dist-server/service/app-binding/app-binding-query.js +14 -7
  15. package/dist-server/service/app-binding/app-binding-query.js.map +1 -1
  16. package/dist-server/service/app-binding/app-binding.d.ts +9 -0
  17. package/dist-server/service/app-binding/app-binding.js +10 -1
  18. package/dist-server/service/app-binding/app-binding.js.map +1 -1
  19. package/dist-server/service/appliance/appliance-mutation.js +34 -1
  20. package/dist-server/service/appliance/appliance-mutation.js.map +1 -1
  21. package/dist-server/service/appliance/appliance-query.d.ts +2 -0
  22. package/dist-server/service/appliance/appliance-query.js +48 -0
  23. package/dist-server/service/appliance/appliance-query.js.map +1 -1
  24. package/dist-server/service/appliance/appliance.d.ts +1 -0
  25. package/dist-server/service/appliance/appliance.js +33 -3
  26. package/dist-server/service/appliance/appliance.js.map +1 -1
  27. package/dist-server/service/application/application-mutation.js +51 -6
  28. package/dist-server/service/application/application-mutation.js.map +1 -1
  29. package/dist-server/service/application/application.d.ts +9 -3
  30. package/dist-server/service/application/application.js +24 -8
  31. package/dist-server/service/application/application.js.map +1 -1
  32. package/dist-server/service/auth-provider/auth-provider-mutation.js +5 -0
  33. package/dist-server/service/auth-provider/auth-provider-mutation.js.map +1 -1
  34. package/dist-server/service/domain-generator/domain-generator-mutation.js +3 -0
  35. package/dist-server/service/domain-generator/domain-generator-mutation.js.map +1 -1
  36. package/dist-server/service/invitation/invitation-mutation.d.ts +17 -15
  37. package/dist-server/service/invitation/invitation-mutation.js +83 -56
  38. package/dist-server/service/invitation/invitation-mutation.js.map +1 -1
  39. package/dist-server/service/invitation/invitation-query.d.ts +20 -5
  40. package/dist-server/service/invitation/invitation-query.js +60 -19
  41. package/dist-server/service/invitation/invitation-query.js.map +1 -1
  42. package/dist-server/service/invitation/invitation.d.ts +14 -2
  43. package/dist-server/service/invitation/invitation.js +77 -8
  44. package/dist-server/service/invitation/invitation.js.map +1 -1
  45. package/dist-server/service/login-history/login-history-query.js +3 -0
  46. package/dist-server/service/login-history/login-history-query.js.map +1 -1
  47. package/dist-server/service/role/role-mutation.js +14 -15
  48. package/dist-server/service/role/role-mutation.js.map +1 -1
  49. package/dist-server/service/role/role-query.js +26 -4
  50. package/dist-server/service/role/role-query.js.map +1 -1
  51. package/dist-server/service/role/role-types.d.ts +2 -0
  52. package/dist-server/service/role/role-types.js +8 -0
  53. package/dist-server/service/role/role-types.js.map +1 -1
  54. package/dist-server/service/role-template/role-template-mutation.d.ts +17 -1
  55. package/dist-server/service/role-template/role-template-mutation.js +14 -3
  56. package/dist-server/service/role-template/role-template-mutation.js.map +1 -1
  57. package/dist-server/service/user/user-mutation.js +1 -0
  58. package/dist-server/service/user/user-mutation.js.map +1 -1
  59. package/dist-server/service/user/user.d.ts +1 -0
  60. package/dist-server/service/user/user.js +19 -4
  61. package/dist-server/service/user/user.js.map +1 -1
  62. package/dist-server/templates/invitation-email.d.ts +2 -1
  63. package/dist-server/templates/invitation-email.js +16 -4
  64. package/dist-server/templates/invitation-email.js.map +1 -1
  65. package/dist-server/tsconfig.tsbuildinfo +1 -1
  66. package/dist-server/utils/credential-at-rest.d.ts +28 -0
  67. package/dist-server/utils/credential-at-rest.js +36 -0
  68. package/dist-server/utils/credential-at-rest.js.map +1 -0
  69. package/dist-server/utils/credential-features.d.ts +35 -0
  70. package/dist-server/utils/credential-features.js +45 -0
  71. package/dist-server/utils/credential-features.js.map +1 -0
  72. package/dist-server/utils/credential-serial-rule.d.ts +41 -0
  73. package/dist-server/utils/credential-serial-rule.js +43 -0
  74. package/dist-server/utils/credential-serial-rule.js.map +1 -0
  75. package/dist-server/utils/invitation-state.d.ts +52 -0
  76. package/dist-server/utils/invitation-state.js +76 -0
  77. package/dist-server/utils/invitation-state.js.map +1 -0
  78. package/dist-server/utils/refuse-role-name.d.ts +42 -0
  79. package/dist-server/utils/refuse-role-name.js +76 -0
  80. package/dist-server/utils/refuse-role-name.js.map +1 -0
  81. package/dist-server/utils/role-name-standing.d.ts +58 -0
  82. package/dist-server/utils/role-name-standing.js +72 -0
  83. package/dist-server/utils/role-name-standing.js.map +1 -0
  84. package/package.json +4 -4
  85. package/tests/app-binding-delete-db.test.ts +188 -0
  86. package/tests/appliance-credential-state-db.test.ts +207 -0
  87. package/tests/appliance-delete-db.test.ts +119 -0
  88. package/tests/appliance-revoke-db.test.ts +73 -28
  89. package/tests/appliance-schema.test.ts +129 -0
  90. package/tests/application-delete-db.test.ts +190 -0
  91. package/tests/application-token-payload-db.test.ts +185 -0
  92. package/tests/checkin-privilege-seam-db.test.ts +158 -0
  93. package/tests/credential-at-rest-db.test.ts +246 -0
  94. package/tests/credential-features-off-db.test.ts +189 -0
  95. package/tests/credential-serial-rule.test.ts +84 -0
  96. package/tests/invitation-db.test.ts +416 -0
  97. package/tests/invitation-state.test.ts +92 -0
  98. package/tests/open-doors.test.ts +132 -0
  99. package/tests/role-inheritance-db.test.ts +286 -0
  100. package/tests/role-mutation-db.test.ts +21 -7
  101. package/tests/role-name-lookup-sentinel.test.ts +79 -0
  102. package/tests/role-name-standing.test.ts +83 -0
  103. package/tests/token-issuance.test.ts +7 -3
  104. package/translations/en.json +2 -0
  105. package/translations/ja.json +2 -0
  106. package/translations/ko.json +2 -0
  107. package/translations/ms.json +2 -0
  108. package/translations/zh.json +2 -0
@@ -0,0 +1,28 @@
1
+ import { ValueTransformer } from 'typeorm';
2
+ /**
3
+ * A credential column that encrypts only where the installation asked for it (ADR-0085).
4
+ *
5
+ * ── The switch governs writing. Reading always takes both ───────────────────
6
+ * `shell`'s `encryptTransformer` reads a value that is not ciphertext-shaped as plaintext, so
7
+ * rows written before the switch was turned on stay readable, and each row converts the next
8
+ * time something writes it. That behaviour does **not** move with the switch — if it did,
9
+ * turning the feature off would lose every row written while it was on.
10
+ *
11
+ * So off means "write it the way we always did", not "pretend the encrypted rows are not
12
+ * there".
13
+ *
14
+ * ── The switch is read per write, not once at import ────────────────────────
15
+ * A module-level constant would freeze whatever the config looked like when the entity file was
16
+ * first loaded, which is not the same thing in tests and not obviously the same thing at boot.
17
+ * `to` asks each time; it is a property lookup.
18
+ *
19
+ * ── Where this is NOT used: `User.password` ─────────────────────────────────
20
+ * That column holds a different thing depending on who the row is. For a person it is an HMAC
21
+ * of their password — already not the secret, so encrypting it protects nothing. For an
22
+ * appliance or an app binding it is the token itself, which is exactly what this is for.
23
+ *
24
+ * A column transformer cannot tell those apart, and encrypting people's hashes to reach the
25
+ * machines' tokens is the wrong trade. The machine credential belongs somewhere of its own;
26
+ * that is the Principal / Credential split, §8 step 4 of `docs/design/auth-rebuild.md`.
27
+ */
28
+ export declare const credentialAtRestTransformer: ValueTransformer;
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.credentialAtRestTransformer = void 0;
4
+ const shell_1 = require("@things-factory/shell");
5
+ const credential_features_js_1 = require("./credential-features.js");
6
+ /**
7
+ * A credential column that encrypts only where the installation asked for it (ADR-0085).
8
+ *
9
+ * ── The switch governs writing. Reading always takes both ───────────────────
10
+ * `shell`'s `encryptTransformer` reads a value that is not ciphertext-shaped as plaintext, so
11
+ * rows written before the switch was turned on stay readable, and each row converts the next
12
+ * time something writes it. That behaviour does **not** move with the switch — if it did,
13
+ * turning the feature off would lose every row written while it was on.
14
+ *
15
+ * So off means "write it the way we always did", not "pretend the encrypted rows are not
16
+ * there".
17
+ *
18
+ * ── The switch is read per write, not once at import ────────────────────────
19
+ * A module-level constant would freeze whatever the config looked like when the entity file was
20
+ * first loaded, which is not the same thing in tests and not obviously the same thing at boot.
21
+ * `to` asks each time; it is a property lookup.
22
+ *
23
+ * ── Where this is NOT used: `User.password` ─────────────────────────────────
24
+ * That column holds a different thing depending on who the row is. For a person it is an HMAC
25
+ * of their password — already not the secret, so encrypting it protects nothing. For an
26
+ * appliance or an app binding it is the token itself, which is exactly what this is for.
27
+ *
28
+ * A column transformer cannot tell those apart, and encrypting people's hashes to reach the
29
+ * machines' tokens is the wrong trade. The machine credential belongs somewhere of its own;
30
+ * that is the Principal / Credential split, §8 step 4 of `docs/design/auth-rebuild.md`.
31
+ */
32
+ exports.credentialAtRestTransformer = {
33
+ to: (entityValue) => ((0, credential_features_js_1.encryptsCredentialsAtRest)() ? shell_1.encryptTransformer.to(entityValue) : entityValue),
34
+ from: (databaseValue) => shell_1.encryptTransformer.from(databaseValue)
35
+ };
36
+ //# sourceMappingURL=credential-at-rest.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credential-at-rest.js","sourceRoot":"","sources":["../../server/utils/credential-at-rest.ts"],"names":[],"mappings":";;;AAEA,iDAA0D;AAE1D,qEAAoE;AAEpE;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACU,QAAA,2BAA2B,GAAqB;IAC3D,EAAE,EAAE,CAAC,WAAmB,EAAE,EAAE,CAAC,CAAC,IAAA,kDAAyB,GAAE,CAAC,CAAC,CAAC,0BAAkB,CAAC,EAAE,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC;IAE7G,IAAI,EAAE,CAAC,aAAqB,EAAE,EAAE,CAAC,0BAAkB,CAAC,IAAI,CAAC,aAAa,CAAC;CACxE,CAAA","sourcesContent":["import { ValueTransformer } from 'typeorm'\n\nimport { encryptTransformer } from '@things-factory/shell'\n\nimport { encryptsCredentialsAtRest } from './credential-features.js'\n\n/**\n * A credential column that encrypts only where the installation asked for it (ADR-0085).\n *\n * ── The switch governs writing. Reading always takes both ───────────────────\n * `shell`'s `encryptTransformer` reads a value that is not ciphertext-shaped as plaintext, so\n * rows written before the switch was turned on stay readable, and each row converts the next\n * time something writes it. That behaviour does **not** move with the switch — if it did,\n * turning the feature off would lose every row written while it was on.\n *\n * So off means \"write it the way we always did\", not \"pretend the encrypted rows are not\n * there\".\n *\n * ── The switch is read per write, not once at import ────────────────────────\n * A module-level constant would freeze whatever the config looked like when the entity file was\n * first loaded, which is not the same thing in tests and not obviously the same thing at boot.\n * `to` asks each time; it is a property lookup.\n *\n * ── Where this is NOT used: `User.password` ─────────────────────────────────\n * That column holds a different thing depending on who the row is. For a person it is an HMAC\n * of their password — already not the secret, so encrypting it protects nothing. For an\n * appliance or an app binding it is the token itself, which is exactly what this is for.\n *\n * A column transformer cannot tell those apart, and encrypting people's hashes to reach the\n * machines' tokens is the wrong trade. The machine credential belongs somewhere of its own;\n * that is the Principal / Credential split, §8 step 4 of `docs/design/auth-rebuild.md`.\n */\nexport const credentialAtRestTransformer: ValueTransformer = {\n to: (entityValue: string) => (encryptsCredentialsAtRest() ? encryptTransformer.to(entityValue) : entityValue),\n\n from: (databaseValue: string) => encryptTransformer.from(databaseValue)\n}\n"]}
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The two credential features an installation turns on for itself (ADR-0085).
3
+ *
4
+ * ── Why they are switches and not just code ─────────────────────────────────
5
+ * An appliance token lives a year, and the ones already out in the field were issued by the
6
+ * version running now. Shipping either feature as plain behaviour would change what those
7
+ * deployments do the moment they take the release. So the installation decides, and a
8
+ * deployment that changes nothing keeps working exactly as it does today.
9
+ *
10
+ * `config.get(key, false)` answers the default when the key is absent, so "not configured"
11
+ * and "off" are the same thing here.
12
+ *
13
+ * ⚠ That `get` treats every falsy value as absent, so writing `false` in the config file also
14
+ * lands on the default. It matches while the default is `false`. **Do not flip these defaults
15
+ * to `true`** — turning them off again would stop working, and the only way off would be to
16
+ * delete the key.
17
+ */
18
+ /**
19
+ * Store credentials as ciphertext rather than plaintext.
20
+ *
21
+ * ⚠ This governs **writing only**. Reading always accepts both shapes — see
22
+ * `shell/server/typeorm/encrypt-transform.ts`. If reading followed the switch, turning the
23
+ * feature off would lose every row written while it was on.
24
+ */
25
+ export declare function encryptsCredentialsAtRest(): boolean;
26
+ /**
27
+ * Offer revocation of an issued credential.
28
+ *
29
+ * ⚠ This governs **the door only** — whether the mutation and its screen exist. It does not
30
+ * govern whether an existing revocation holds. A credential revoked while this was on stays
31
+ * revoked after it is turned off, because a lost device must not come back through a config
32
+ * change. The check that enforces it does nothing on its own: a subject that was never
33
+ * revoked carries no serial, and the comparison never fires.
34
+ */
35
+ export declare function offersCredentialRevocation(): boolean;
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.encryptsCredentialsAtRest = encryptsCredentialsAtRest;
4
+ exports.offersCredentialRevocation = offersCredentialRevocation;
5
+ const env_1 = require("@things-factory/env");
6
+ /**
7
+ * The two credential features an installation turns on for itself (ADR-0085).
8
+ *
9
+ * ── Why they are switches and not just code ─────────────────────────────────
10
+ * An appliance token lives a year, and the ones already out in the field were issued by the
11
+ * version running now. Shipping either feature as plain behaviour would change what those
12
+ * deployments do the moment they take the release. So the installation decides, and a
13
+ * deployment that changes nothing keeps working exactly as it does today.
14
+ *
15
+ * `config.get(key, false)` answers the default when the key is absent, so "not configured"
16
+ * and "off" are the same thing here.
17
+ *
18
+ * ⚠ That `get` treats every falsy value as absent, so writing `false` in the config file also
19
+ * lands on the default. It matches while the default is `false`. **Do not flip these defaults
20
+ * to `true`** — turning them off again would stop working, and the only way off would be to
21
+ * delete the key.
22
+ */
23
+ /**
24
+ * Store credentials as ciphertext rather than plaintext.
25
+ *
26
+ * ⚠ This governs **writing only**. Reading always accepts both shapes — see
27
+ * `shell/server/typeorm/encrypt-transform.ts`. If reading followed the switch, turning the
28
+ * feature off would lose every row written while it was on.
29
+ */
30
+ function encryptsCredentialsAtRest() {
31
+ return !!env_1.config.get('credential/encryptAtRest', false);
32
+ }
33
+ /**
34
+ * Offer revocation of an issued credential.
35
+ *
36
+ * ⚠ This governs **the door only** — whether the mutation and its screen exist. It does not
37
+ * govern whether an existing revocation holds. A credential revoked while this was on stays
38
+ * revoked after it is turned off, because a lost device must not come back through a config
39
+ * change. The check that enforces it does nothing on its own: a subject that was never
40
+ * revoked carries no serial, and the comparison never fires.
41
+ */
42
+ function offersCredentialRevocation() {
43
+ return !!env_1.config.get('credential/revocable', false);
44
+ }
45
+ //# sourceMappingURL=credential-features.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credential-features.js","sourceRoot":"","sources":["../../server/utils/credential-features.ts"],"names":[],"mappings":";;AA2BA,8DAEC;AAWD,gEAEC;AA1CD,6CAA4C;AAE5C;;;;;;;;;;;;;;;;GAgBG;AAEH;;;;;;GAMG;AACH,SAAgB,yBAAyB;IACvC,OAAO,CAAC,CAAC,YAAM,CAAC,GAAG,CAAC,0BAA0B,EAAE,KAAK,CAAC,CAAA;AACxD,CAAC;AAED;;;;;;;;GAQG;AACH,SAAgB,0BAA0B;IACxC,OAAO,CAAC,CAAC,YAAM,CAAC,GAAG,CAAC,sBAAsB,EAAE,KAAK,CAAC,CAAA;AACpD,CAAC","sourcesContent":["import { config } from '@things-factory/env'\n\n/**\n * The two credential features an installation turns on for itself (ADR-0085).\n *\n * ── Why they are switches and not just code ─────────────────────────────────\n * An appliance token lives a year, and the ones already out in the field were issued by the\n * version running now. Shipping either feature as plain behaviour would change what those\n * deployments do the moment they take the release. So the installation decides, and a\n * deployment that changes nothing keeps working exactly as it does today.\n *\n * `config.get(key, false)` answers the default when the key is absent, so \"not configured\"\n * and \"off\" are the same thing here.\n *\n * ⚠ That `get` treats every falsy value as absent, so writing `false` in the config file also\n * lands on the default. It matches while the default is `false`. **Do not flip these defaults\n * to `true`** — turning them off again would stop working, and the only way off would be to\n * delete the key.\n */\n\n/**\n * Store credentials as ciphertext rather than plaintext.\n *\n * ⚠ This governs **writing only**. Reading always accepts both shapes — see\n * `shell/server/typeorm/encrypt-transform.ts`. If reading followed the switch, turning the\n * feature off would lose every row written while it was on.\n */\nexport function encryptsCredentialsAtRest(): boolean {\n return !!config.get('credential/encryptAtRest', false)\n}\n\n/**\n * Offer revocation of an issued credential.\n *\n * ⚠ This governs **the door only** — whether the mutation and its screen exist. It does not\n * govern whether an existing revocation holds. A credential revoked while this was on stays\n * revoked after it is turned off, because a lost device must not come back through a config\n * change. The check that enforces it does nothing on its own: a subject that was never\n * revoked carries no serial, and the comparison never fires.\n */\nexport function offersCredentialRevocation(): boolean {\n return !!config.get('credential/revocable', false)\n}\n"]}
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Does this token still name the current generation of its subject's credential? (Pure rule.)
3
+ *
4
+ * ## Why the rule is its own function
5
+ *
6
+ * `checkAuth` runs on every request, and this is the one line in it that can refuse a token
7
+ * whose signature is perfectly good. A rule that decides that belongs somewhere it can be read
8
+ * and tested on its own, next to `account-lock-rule` and `checkin-domain-rule`.
9
+ *
10
+ * ## What an absent serial means, and why it must pass
11
+ *
12
+ * An appliance token lives a year. The ones in the field were signed before the column existed,
13
+ * so they carry no serial, and the subjects they name have none recorded. **Those have to pass**
14
+ * — otherwise taking the release cuts off every device at once, and the point of the design was
15
+ * that it does not (ADR-0085 decision 3).
16
+ *
17
+ * A subject gains a serial the first time its credential is issued or revoked. From that moment
18
+ * its tokens have to carry the same number, and an older one no longer does.
19
+ *
20
+ * ## Why this closes what a status could not
21
+ *
22
+ * Revocation used to live entirely in the shadow user's status, which meant putting the status
23
+ * back let every token ever issued to that appliance work again. The serial moves on revoke and
24
+ * never moves back, so reactivating a subject does not resurrect its old credentials — only
25
+ * issuing a new one does, and that one carries the new number.
26
+ */
27
+ /** The parts of the subject this rule reads. */
28
+ export type CredentialSubject = {
29
+ credentialSerial?: number | null;
30
+ };
31
+ /** The parts of a verified token this rule reads. `cs` is absent on anything issued earlier. */
32
+ export type CredentialClaims = {
33
+ cs?: number | null;
34
+ };
35
+ /**
36
+ * True when the token may still be used for this subject.
37
+ *
38
+ * Absent-on-the-subject means this subject has never been through the new path, and everything
39
+ * passes. Present means the token has to match it exactly.
40
+ */
41
+ export declare function credentialSerialMatches(subject: CredentialSubject, claims: CredentialClaims): boolean;
@@ -0,0 +1,43 @@
1
+ "use strict";
2
+ /**
3
+ * Does this token still name the current generation of its subject's credential? (Pure rule.)
4
+ *
5
+ * ## Why the rule is its own function
6
+ *
7
+ * `checkAuth` runs on every request, and this is the one line in it that can refuse a token
8
+ * whose signature is perfectly good. A rule that decides that belongs somewhere it can be read
9
+ * and tested on its own, next to `account-lock-rule` and `checkin-domain-rule`.
10
+ *
11
+ * ## What an absent serial means, and why it must pass
12
+ *
13
+ * An appliance token lives a year. The ones in the field were signed before the column existed,
14
+ * so they carry no serial, and the subjects they name have none recorded. **Those have to pass**
15
+ * — otherwise taking the release cuts off every device at once, and the point of the design was
16
+ * that it does not (ADR-0085 decision 3).
17
+ *
18
+ * A subject gains a serial the first time its credential is issued or revoked. From that moment
19
+ * its tokens have to carry the same number, and an older one no longer does.
20
+ *
21
+ * ## Why this closes what a status could not
22
+ *
23
+ * Revocation used to live entirely in the shadow user's status, which meant putting the status
24
+ * back let every token ever issued to that appliance work again. The serial moves on revoke and
25
+ * never moves back, so reactivating a subject does not resurrect its old credentials — only
26
+ * issuing a new one does, and that one carries the new number.
27
+ */
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.credentialSerialMatches = credentialSerialMatches;
30
+ /**
31
+ * True when the token may still be used for this subject.
32
+ *
33
+ * Absent-on-the-subject means this subject has never been through the new path, and everything
34
+ * passes. Present means the token has to match it exactly.
35
+ */
36
+ function credentialSerialMatches(subject, claims) {
37
+ const current = subject?.credentialSerial;
38
+ if (current === null || current === undefined) {
39
+ return true;
40
+ }
41
+ return claims?.cs === current;
42
+ }
43
+ //# sourceMappingURL=credential-serial-rule.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credential-serial-rule.js","sourceRoot":"","sources":["../../server/utils/credential-serial-rule.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;;AAkBH,0DAQC;AAdD;;;;;GAKG;AACH,SAAgB,uBAAuB,CAAC,OAA0B,EAAE,MAAwB;IAC1F,MAAM,OAAO,GAAG,OAAO,EAAE,gBAAgB,CAAA;IAEzC,IAAI,OAAO,KAAK,IAAI,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;QAC9C,OAAO,IAAI,CAAA;IACb,CAAC;IAED,OAAO,MAAM,EAAE,EAAE,KAAK,OAAO,CAAA;AAC/B,CAAC","sourcesContent":["/**\n * Does this token still name the current generation of its subject's credential? (Pure rule.)\n *\n * ## Why the rule is its own function\n *\n * `checkAuth` runs on every request, and this is the one line in it that can refuse a token\n * whose signature is perfectly good. A rule that decides that belongs somewhere it can be read\n * and tested on its own, next to `account-lock-rule` and `checkin-domain-rule`.\n *\n * ## What an absent serial means, and why it must pass\n *\n * An appliance token lives a year. The ones in the field were signed before the column existed,\n * so they carry no serial, and the subjects they name have none recorded. **Those have to pass**\n * — otherwise taking the release cuts off every device at once, and the point of the design was\n * that it does not (ADR-0085 decision 3).\n *\n * A subject gains a serial the first time its credential is issued or revoked. From that moment\n * its tokens have to carry the same number, and an older one no longer does.\n *\n * ## Why this closes what a status could not\n *\n * Revocation used to live entirely in the shadow user's status, which meant putting the status\n * back let every token ever issued to that appliance work again. The serial moves on revoke and\n * never moves back, so reactivating a subject does not resurrect its old credentials — only\n * issuing a new one does, and that one carries the new number.\n */\n\n/** The parts of the subject this rule reads. */\nexport type CredentialSubject = {\n credentialSerial?: number | null\n}\n\n/** The parts of a verified token this rule reads. `cs` is absent on anything issued earlier. */\nexport type CredentialClaims = {\n cs?: number | null\n}\n\n/**\n * True when the token may still be used for this subject.\n *\n * Absent-on-the-subject means this subject has never been through the new path, and everything\n * passes. Present means the token has to match it exactly.\n */\nexport function credentialSerialMatches(subject: CredentialSubject, claims: CredentialClaims): boolean {\n const current = subject?.credentialSerial\n\n if (current === null || current === undefined) {\n return true\n }\n\n return claims?.cs === current\n}\n"]}
@@ -0,0 +1,52 @@
1
+ /**
2
+ * What state an invitation is in, and whether it can still be accepted. (Pure rule.)
3
+ *
4
+ * ## Why the table keeps rows now
5
+ *
6
+ * Accepting used to delete the row and cancelling still does, so the table was a queue of
7
+ * pending invitations and nothing else. The moment somebody joined, the fact that they had been
8
+ * invited stopped existing — which is why no screen was ever built on it, and why nothing calls
9
+ * any of it. An invitation list has to be able to say *who was invited, by whom, when, and
10
+ * whether they came in*.
11
+ *
12
+ * So the row survives and carries its state. `pending` → one of the other three, and never back.
13
+ *
14
+ * ## Why expiry is derived and not only stored
15
+ *
16
+ * A token that outlives its invitation is a credential nobody is watching. If `expired` were
17
+ * only ever a written value, it would depend on something running to write it, and the gap
18
+ * between the expiry passing and that thing running is a window where the token still works.
19
+ * So the rule reads the clock: a pending row past its `expiresAt` **is** expired, whatever the
20
+ * column says. Writing the status is then bookkeeping for the list, not the guard.
21
+ *
22
+ * ## What an absent value means, and why it refuses
23
+ *
24
+ * Rows written before this existed have neither status nor expiry. They come from a flow that
25
+ * never completed — the link in the email led nowhere — so treating them as live would keep
26
+ * tokens alive that were never usable in the first place.
27
+ *
28
+ * status absent reads as `pending`, which is what the old table meant by a row existing
29
+ * expiresAt absent reads as **expired**. An invitation that cannot say when it dies is not
30
+ * a live invitation, and this is a credential: absence refuses.
31
+ */
32
+ /** The states an invitation row can be in. Stored, and also derived for expiry. */
33
+ export declare enum InvitationStatus {
34
+ PENDING = "pending",
35
+ ACCEPTED = "accepted",
36
+ CANCELLED = "cancelled",
37
+ EXPIRED = "expired"
38
+ }
39
+ /** The parts of an invitation this rule reads. */
40
+ export type InvitationRecord = {
41
+ status?: string | null;
42
+ expiresAt?: Date | string | null;
43
+ } | null | undefined;
44
+ /**
45
+ * The state this invitation is in, as of `now`.
46
+ *
47
+ * Only a pending row is measured against the clock. Once a row has been accepted or cancelled
48
+ * that is what happened to it, and time passing does not rewrite history.
49
+ */
50
+ export declare function invitationState(invitation: InvitationRecord, now?: Date): InvitationStatus;
51
+ /** May this invitation still be accepted? Only a pending one that has not run out. */
52
+ export declare function mayAccept(invitation: InvitationRecord, now?: Date): boolean;
@@ -0,0 +1,76 @@
1
+ "use strict";
2
+ /**
3
+ * What state an invitation is in, and whether it can still be accepted. (Pure rule.)
4
+ *
5
+ * ## Why the table keeps rows now
6
+ *
7
+ * Accepting used to delete the row and cancelling still does, so the table was a queue of
8
+ * pending invitations and nothing else. The moment somebody joined, the fact that they had been
9
+ * invited stopped existing — which is why no screen was ever built on it, and why nothing calls
10
+ * any of it. An invitation list has to be able to say *who was invited, by whom, when, and
11
+ * whether they came in*.
12
+ *
13
+ * So the row survives and carries its state. `pending` → one of the other three, and never back.
14
+ *
15
+ * ## Why expiry is derived and not only stored
16
+ *
17
+ * A token that outlives its invitation is a credential nobody is watching. If `expired` were
18
+ * only ever a written value, it would depend on something running to write it, and the gap
19
+ * between the expiry passing and that thing running is a window where the token still works.
20
+ * So the rule reads the clock: a pending row past its `expiresAt` **is** expired, whatever the
21
+ * column says. Writing the status is then bookkeeping for the list, not the guard.
22
+ *
23
+ * ## What an absent value means, and why it refuses
24
+ *
25
+ * Rows written before this existed have neither status nor expiry. They come from a flow that
26
+ * never completed — the link in the email led nowhere — so treating them as live would keep
27
+ * tokens alive that were never usable in the first place.
28
+ *
29
+ * status absent reads as `pending`, which is what the old table meant by a row existing
30
+ * expiresAt absent reads as **expired**. An invitation that cannot say when it dies is not
31
+ * a live invitation, and this is a credential: absence refuses.
32
+ */
33
+ Object.defineProperty(exports, "__esModule", { value: true });
34
+ exports.InvitationStatus = void 0;
35
+ exports.invitationState = invitationState;
36
+ exports.mayAccept = mayAccept;
37
+ /** The states an invitation row can be in. Stored, and also derived for expiry. */
38
+ var InvitationStatus;
39
+ (function (InvitationStatus) {
40
+ InvitationStatus["PENDING"] = "pending";
41
+ InvitationStatus["ACCEPTED"] = "accepted";
42
+ InvitationStatus["CANCELLED"] = "cancelled";
43
+ InvitationStatus["EXPIRED"] = "expired";
44
+ })(InvitationStatus || (exports.InvitationStatus = InvitationStatus = {}));
45
+ function expiryPassed(expiresAt, now) {
46
+ if (!expiresAt) {
47
+ /* No expiry recorded — see the note above. Absence refuses. */
48
+ return true;
49
+ }
50
+ const at = expiresAt instanceof Date ? expiresAt : new Date(expiresAt);
51
+ if (Number.isNaN(at.getTime())) {
52
+ return true;
53
+ }
54
+ return at.getTime() <= now.getTime();
55
+ }
56
+ /**
57
+ * The state this invitation is in, as of `now`.
58
+ *
59
+ * Only a pending row is measured against the clock. Once a row has been accepted or cancelled
60
+ * that is what happened to it, and time passing does not rewrite history.
61
+ */
62
+ function invitationState(invitation, now = new Date()) {
63
+ const status = invitation?.status || InvitationStatus.PENDING;
64
+ if (status === InvitationStatus.ACCEPTED || status === InvitationStatus.CANCELLED) {
65
+ return status;
66
+ }
67
+ if (status === InvitationStatus.EXPIRED) {
68
+ return InvitationStatus.EXPIRED;
69
+ }
70
+ return expiryPassed(invitation?.expiresAt, now) ? InvitationStatus.EXPIRED : InvitationStatus.PENDING;
71
+ }
72
+ /** May this invitation still be accepted? Only a pending one that has not run out. */
73
+ function mayAccept(invitation, now = new Date()) {
74
+ return !!invitation && invitationState(invitation, now) === InvitationStatus.PENDING;
75
+ }
76
+ //# sourceMappingURL=invitation-state.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"invitation-state.js","sourceRoot":"","sources":["../../server/utils/invitation-state.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;;;AAqCH,0CAYC;AAGD,8BAEC;AApDD,mFAAmF;AACnF,IAAY,gBAKX;AALD,WAAY,gBAAgB;IAC1B,uCAAmB,CAAA;IACnB,yCAAqB,CAAA;IACrB,2CAAuB,CAAA;IACvB,uCAAmB,CAAA;AACrB,CAAC,EALW,gBAAgB,gCAAhB,gBAAgB,QAK3B;AAQD,SAAS,YAAY,CAAC,SAA2C,EAAE,GAAS;IAC1E,IAAI,CAAC,SAAS,EAAE,CAAC;QACf,+DAA+D;QAC/D,OAAO,IAAI,CAAA;IACb,CAAC;IAED,MAAM,EAAE,GAAG,SAAS,YAAY,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,SAAS,CAAC,CAAA;IAEtE,IAAI,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC;QAC/B,OAAO,IAAI,CAAA;IACb,CAAC;IAED,OAAO,EAAE,CAAC,OAAO,EAAE,IAAI,GAAG,CAAC,OAAO,EAAE,CAAA;AACtC,CAAC;AAED;;;;;GAKG;AACH,SAAgB,eAAe,CAAC,UAA4B,EAAE,MAAY,IAAI,IAAI,EAAE;IAClF,MAAM,MAAM,GAAG,UAAU,EAAE,MAAM,IAAI,gBAAgB,CAAC,OAAO,CAAA;IAE7D,IAAI,MAAM,KAAK,gBAAgB,CAAC,QAAQ,IAAI,MAAM,KAAK,gBAAgB,CAAC,SAAS,EAAE,CAAC;QAClF,OAAO,MAA0B,CAAA;IACnC,CAAC;IAED,IAAI,MAAM,KAAK,gBAAgB,CAAC,OAAO,EAAE,CAAC;QACxC,OAAO,gBAAgB,CAAC,OAAO,CAAA;IACjC,CAAC;IAED,OAAO,YAAY,CAAC,UAAU,EAAE,SAAS,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,gBAAgB,CAAC,OAAO,CAAC,CAAC,CAAC,gBAAgB,CAAC,OAAO,CAAA;AACvG,CAAC;AAED,sFAAsF;AACtF,SAAgB,SAAS,CAAC,UAA4B,EAAE,MAAY,IAAI,IAAI,EAAE;IAC5E,OAAO,CAAC,CAAC,UAAU,IAAI,eAAe,CAAC,UAAU,EAAE,GAAG,CAAC,KAAK,gBAAgB,CAAC,OAAO,CAAA;AACtF,CAAC","sourcesContent":["/**\n * What state an invitation is in, and whether it can still be accepted. (Pure rule.)\n *\n * ## Why the table keeps rows now\n *\n * Accepting used to delete the row and cancelling still does, so the table was a queue of\n * pending invitations and nothing else. The moment somebody joined, the fact that they had been\n * invited stopped existing — which is why no screen was ever built on it, and why nothing calls\n * any of it. An invitation list has to be able to say *who was invited, by whom, when, and\n * whether they came in*.\n *\n * So the row survives and carries its state. `pending` → one of the other three, and never back.\n *\n * ## Why expiry is derived and not only stored\n *\n * A token that outlives its invitation is a credential nobody is watching. If `expired` were\n * only ever a written value, it would depend on something running to write it, and the gap\n * between the expiry passing and that thing running is a window where the token still works.\n * So the rule reads the clock: a pending row past its `expiresAt` **is** expired, whatever the\n * column says. Writing the status is then bookkeeping for the list, not the guard.\n *\n * ## What an absent value means, and why it refuses\n *\n * Rows written before this existed have neither status nor expiry. They come from a flow that\n * never completed — the link in the email led nowhere — so treating them as live would keep\n * tokens alive that were never usable in the first place.\n *\n * status absent reads as `pending`, which is what the old table meant by a row existing\n * expiresAt absent reads as **expired**. An invitation that cannot say when it dies is not\n * a live invitation, and this is a credential: absence refuses.\n */\n\n/** The states an invitation row can be in. Stored, and also derived for expiry. */\nexport enum InvitationStatus {\n PENDING = 'pending',\n ACCEPTED = 'accepted',\n CANCELLED = 'cancelled',\n EXPIRED = 'expired'\n}\n\n/** The parts of an invitation this rule reads. */\nexport type InvitationRecord = {\n status?: string | null\n expiresAt?: Date | string | null\n} | null | undefined\n\nfunction expiryPassed(expiresAt: Date | string | null | undefined, now: Date): boolean {\n if (!expiresAt) {\n /* No expiry recorded — see the note above. Absence refuses. */\n return true\n }\n\n const at = expiresAt instanceof Date ? expiresAt : new Date(expiresAt)\n\n if (Number.isNaN(at.getTime())) {\n return true\n }\n\n return at.getTime() <= now.getTime()\n}\n\n/**\n * The state this invitation is in, as of `now`.\n *\n * Only a pending row is measured against the clock. Once a row has been accepted or cancelled\n * that is what happened to it, and time passing does not rewrite history.\n */\nexport function invitationState(invitation: InvitationRecord, now: Date = new Date()): InvitationStatus {\n const status = invitation?.status || InvitationStatus.PENDING\n\n if (status === InvitationStatus.ACCEPTED || status === InvitationStatus.CANCELLED) {\n return status as InvitationStatus\n }\n\n if (status === InvitationStatus.EXPIRED) {\n return InvitationStatus.EXPIRED\n }\n\n return expiryPassed(invitation?.expiresAt, now) ? InvitationStatus.EXPIRED : InvitationStatus.PENDING\n}\n\n/** May this invitation still be accepted? Only a pending one that has not run out. */\nexport function mayAccept(invitation: InvitationRecord, now: Date = new Date()): boolean {\n return !!invitation && invitationState(invitation, now) === InvitationStatus.PENDING\n}\n"]}
@@ -0,0 +1,42 @@
1
+ import { EntityManager, Repository, SelectQueryBuilder } from 'typeorm';
2
+ import { Role } from '../service/role/role.js';
3
+ import { type AskingDomain } from './role-name-standing.js';
4
+ /**
5
+ * The one place a role name is checked before it is written (ADR-0062 decision 3).
6
+ *
7
+ * ── Why it is one place ─────────────────────────────────────────────────────
8
+ * Five sites write a role name — `createRole`, the rename in `updateRole`, both halves of
9
+ * `updateMultipleRoles`, and `createRoleFromTemplate`. All five asked `domain: { id: domain.id }`
10
+ * and none of them looked at what the domain inherits. Five copies of a check is five chances
11
+ * for one of them to keep the old question.
12
+ *
13
+ * ── Why the refusal names the domain ────────────────────────────────────────
14
+ * The operator's next step differs by where the name stands. If it stands here they rename; if
15
+ * it is inherited they may decide this domain stands its own. They cannot choose without being
16
+ * told which it is and from where — a refusal that only says "duplicated" leaves them guessing
17
+ * at a domain they may not have known existed.
18
+ *
19
+ * ── Why a `Refusal` and not a sentence ──────────────────────────────────────
20
+ * The rest of this file's neighbours throw English strings, which puts English on a Korean
21
+ * screen and makes the screen write the reason itself (ADR-0054). This refusal is new, so it
22
+ * starts as a name and values; the older sentences around it are left alone.
23
+ */
24
+ export declare function refuseRoleNameIfStanding({ name, domain, separately, tx }: {
25
+ name: string;
26
+ domain: AskingDomain;
27
+ separately?: boolean;
28
+ tx?: EntityManager;
29
+ }): Promise<void>;
30
+ /**
31
+ * The read behind the check: a role of this name **in one named domain**.
32
+ *
33
+ * ── Why this is a builder and not a `findOne` ───────────────────────────────
34
+ * One domain at a time is the whole point, and the harness that would catch it going back to
35
+ * both is sqlite — which returns the rows in an order that happens to be the right answer, so it
36
+ * cannot fail the way a server does. The repository's own note says as much about column
37
+ * dialects, and `domain-inheritance-sentinel.test.ts` says it about this exact class.
38
+ *
39
+ * So the query is something a test can read **before it is sent**. What we build is the same on
40
+ * every driver, which is the only way to reach a defect our sqlite development cannot see.
41
+ */
42
+ export declare function standingRoleQuery(repository: Repository<Role>, name: string, domainId: string): SelectQueryBuilder<Role>;
@@ -0,0 +1,76 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.refuseRoleNameIfStanding = refuseRoleNameIfStanding;
4
+ exports.standingRoleQuery = standingRoleQuery;
5
+ const shell_1 = require("@things-factory/shell");
6
+ const role_js_1 = require("../service/role/role.js");
7
+ const role_name_standing_js_1 = require("./role-name-standing.js");
8
+ /**
9
+ * The one place a role name is checked before it is written (ADR-0062 decision 3).
10
+ *
11
+ * ── Why it is one place ─────────────────────────────────────────────────────
12
+ * Five sites write a role name — `createRole`, the rename in `updateRole`, both halves of
13
+ * `updateMultipleRoles`, and `createRoleFromTemplate`. All five asked `domain: { id: domain.id }`
14
+ * and none of them looked at what the domain inherits. Five copies of a check is five chances
15
+ * for one of them to keep the old question.
16
+ *
17
+ * ── Why the refusal names the domain ────────────────────────────────────────
18
+ * The operator's next step differs by where the name stands. If it stands here they rename; if
19
+ * it is inherited they may decide this domain stands its own. They cannot choose without being
20
+ * told which it is and from where — a refusal that only says "duplicated" leaves them guessing
21
+ * at a domain they may not have known existed.
22
+ *
23
+ * ── Why a `Refusal` and not a sentence ──────────────────────────────────────
24
+ * The rest of this file's neighbours throw English strings, which puts English on a Korean
25
+ * screen and makes the screen write the reason itself (ADR-0054). This refusal is new, so it
26
+ * starts as a name and values; the older sentences around it are left alone.
27
+ */
28
+ async function refuseRoleNameIfStanding({ name, domain, separately, tx }) {
29
+ const repository = (0, shell_1.getRepository)(role_js_1.Role, tx);
30
+ /*
31
+ * ⚠ This domain is asked first, on its own, and the parent only if it has nothing.
32
+ *
33
+ * One read over both with `In([own, parent])` has no `ORDER BY`, so when the name stands in
34
+ * **both** the answer is whichever row the driver hands back. If that is the parent's, the
35
+ * refusal is the inherited one — and `separately` is allowed to pass that, which stands a
36
+ * second row of the name in a domain that already has it. Exactly the state the rule exists to
37
+ * prevent, reached through the rule.
38
+ *
39
+ * Standing in both is not a corner: the migration of ADR-0062 decision 2 passes through it for
40
+ * its whole length, because the domain's own role stands up first and the parent is cut last.
41
+ *
42
+ * This is the same defect that `role(name)` had and the same fix. It went back in here because
43
+ * "one read" looked like the tidier shape.
44
+ */
45
+ const own = await standingRoleQuery(repository, name, domain.id).getOne();
46
+ const found = own || (domain.parentId ? await standingRoleQuery(repository, name, domain.parentId).getOne() : null);
47
+ const standing = (0, role_name_standing_js_1.nameStands)({ domainId: found?.domain?.id || found?.domainId }, domain);
48
+ if (!(0, role_name_standing_js_1.refusesName)(standing, separately)) {
49
+ return;
50
+ }
51
+ if (standing === 'here') {
52
+ throw new shell_1.Refusal('role-name-taken-here', { name }, `This domain already has a role called "${name}".`);
53
+ }
54
+ throw new shell_1.Refusal('role-name-stands-inherited', { name, domain: found?.domain?.name || '' }, `A role called "${name}" is already visible here, defined in "${found?.domain?.name}". ` +
55
+ `Stand this domain's own of that name only if that is what you mean to do.`);
56
+ }
57
+ /**
58
+ * The read behind the check: a role of this name **in one named domain**.
59
+ *
60
+ * ── Why this is a builder and not a `findOne` ───────────────────────────────
61
+ * One domain at a time is the whole point, and the harness that would catch it going back to
62
+ * both is sqlite — which returns the rows in an order that happens to be the right answer, so it
63
+ * cannot fail the way a server does. The repository's own note says as much about column
64
+ * dialects, and `domain-inheritance-sentinel.test.ts` says it about this exact class.
65
+ *
66
+ * So the query is something a test can read **before it is sent**. What we build is the same on
67
+ * every driver, which is the only way to reach a defect our sqlite development cannot see.
68
+ */
69
+ function standingRoleQuery(repository, name, domainId) {
70
+ return repository
71
+ .createQueryBuilder('ROLE')
72
+ .leftJoinAndSelect('ROLE.domain', 'ROLE_DOMAIN')
73
+ .where('ROLE.name = :name', { name })
74
+ .andWhere('ROLE_DOMAIN.id = :domainId', { domainId });
75
+ }
76
+ //# sourceMappingURL=refuse-role-name.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"refuse-role-name.js","sourceRoot":"","sources":["../../server/utils/refuse-role-name.ts"],"names":[],"mappings":";;AA2BA,4DAoDC;AAcD,8CAUC;AArGD,iDAA8D;AAE9D,qDAA8C;AAC9C,mEAAoF;AAEpF;;;;;;;;;;;;;;;;;;;GAmBG;AACI,KAAK,UAAU,wBAAwB,CAAC,EAC7C,IAAI,EACJ,MAAM,EACN,UAAU,EACV,EAAE,EAMH;IACC,MAAM,UAAU,GAAG,IAAA,qBAAa,EAAC,cAAI,EAAE,EAAE,CAAC,CAAA;IAE1C;;;;;;;;;;;;;;OAcG;IACH,MAAM,GAAG,GAAG,MAAM,iBAAiB,CAAC,UAAU,EAAE,IAAI,EAAE,MAAM,CAAC,EAAE,CAAC,CAAC,MAAM,EAAE,CAAA;IAEzE,MAAM,KAAK,GAAG,GAAG,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,MAAM,iBAAiB,CAAC,UAAU,EAAE,IAAI,EAAE,MAAM,CAAC,QAAQ,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAA;IAEnH,MAAM,QAAQ,GAAG,IAAA,kCAAU,EAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,IAAI,KAAK,EAAE,QAAQ,EAAE,EAAE,MAAM,CAAC,CAAA;IAEvF,IAAI,CAAC,IAAA,mCAAW,EAAC,QAAQ,EAAE,UAAU,CAAC,EAAE,CAAC;QACvC,OAAM;IACR,CAAC;IAED,IAAI,QAAQ,KAAK,MAAM,EAAE,CAAC;QACxB,MAAM,IAAI,eAAO,CACf,sBAAsB,EACtB,EAAE,IAAI,EAAE,EACR,0CAA0C,IAAI,IAAI,CACnD,CAAA;IACH,CAAC;IAED,MAAM,IAAI,eAAO,CACf,4BAA4B,EAC5B,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,IAAI,EAAE,EAAE,EAC3C,kBAAkB,IAAI,0CAA0C,KAAK,EAAE,MAAM,EAAE,IAAI,KAAK;QACtF,2EAA2E,CAC9E,CAAA;AACH,CAAC;AAED;;;;;;;;;;;GAWG;AACH,SAAgB,iBAAiB,CAC/B,UAA4B,EAC5B,IAAY,EACZ,QAAgB;IAEhB,OAAO,UAAU;SACd,kBAAkB,CAAC,MAAM,CAAC;SAC1B,iBAAiB,CAAC,aAAa,EAAE,aAAa,CAAC;SAC/C,KAAK,CAAC,mBAAmB,EAAE,EAAE,IAAI,EAAE,CAAC;SACpC,QAAQ,CAAC,4BAA4B,EAAE,EAAE,QAAQ,EAAE,CAAC,CAAA;AACzD,CAAC","sourcesContent":["import { EntityManager, Repository, SelectQueryBuilder } from 'typeorm'\n\nimport { getRepository, Refusal } from '@things-factory/shell'\n\nimport { Role } from '../service/role/role.js'\nimport { nameStands, refusesName, type AskingDomain } from './role-name-standing.js'\n\n/**\n * The one place a role name is checked before it is written (ADR-0062 decision 3).\n *\n * ── Why it is one place ─────────────────────────────────────────────────────\n * Five sites write a role name — `createRole`, the rename in `updateRole`, both halves of\n * `updateMultipleRoles`, and `createRoleFromTemplate`. All five asked `domain: { id: domain.id }`\n * and none of them looked at what the domain inherits. Five copies of a check is five chances\n * for one of them to keep the old question.\n *\n * ── Why the refusal names the domain ────────────────────────────────────────\n * The operator's next step differs by where the name stands. If it stands here they rename; if\n * it is inherited they may decide this domain stands its own. They cannot choose without being\n * told which it is and from where — a refusal that only says \"duplicated\" leaves them guessing\n * at a domain they may not have known existed.\n *\n * ── Why a `Refusal` and not a sentence ──────────────────────────────────────\n * The rest of this file's neighbours throw English strings, which puts English on a Korean\n * screen and makes the screen write the reason itself (ADR-0054). This refusal is new, so it\n * starts as a name and values; the older sentences around it are left alone.\n */\nexport async function refuseRoleNameIfStanding({\n name,\n domain,\n separately,\n tx\n}: {\n name: string\n domain: AskingDomain\n separately?: boolean\n tx?: EntityManager\n}): Promise<void> {\n const repository = getRepository(Role, tx)\n\n /*\n * ⚠ This domain is asked first, on its own, and the parent only if it has nothing.\n *\n * One read over both with `In([own, parent])` has no `ORDER BY`, so when the name stands in\n * **both** the answer is whichever row the driver hands back. If that is the parent's, the\n * refusal is the inherited one — and `separately` is allowed to pass that, which stands a\n * second row of the name in a domain that already has it. Exactly the state the rule exists to\n * prevent, reached through the rule.\n *\n * Standing in both is not a corner: the migration of ADR-0062 decision 2 passes through it for\n * its whole length, because the domain's own role stands up first and the parent is cut last.\n *\n * This is the same defect that `role(name)` had and the same fix. It went back in here because\n * \"one read\" looked like the tidier shape.\n */\n const own = await standingRoleQuery(repository, name, domain.id).getOne()\n\n const found = own || (domain.parentId ? await standingRoleQuery(repository, name, domain.parentId).getOne() : null)\n\n const standing = nameStands({ domainId: found?.domain?.id || found?.domainId }, domain)\n\n if (!refusesName(standing, separately)) {\n return\n }\n\n if (standing === 'here') {\n throw new Refusal(\n 'role-name-taken-here',\n { name },\n `This domain already has a role called \"${name}\".`\n )\n }\n\n throw new Refusal(\n 'role-name-stands-inherited',\n { name, domain: found?.domain?.name || '' },\n `A role called \"${name}\" is already visible here, defined in \"${found?.domain?.name}\". ` +\n `Stand this domain's own of that name only if that is what you mean to do.`\n )\n}\n\n/**\n * The read behind the check: a role of this name **in one named domain**.\n *\n * ── Why this is a builder and not a `findOne` ───────────────────────────────\n * One domain at a time is the whole point, and the harness that would catch it going back to\n * both is sqlite — which returns the rows in an order that happens to be the right answer, so it\n * cannot fail the way a server does. The repository's own note says as much about column\n * dialects, and `domain-inheritance-sentinel.test.ts` says it about this exact class.\n *\n * So the query is something a test can read **before it is sent**. What we build is the same on\n * every driver, which is the only way to reach a defect our sqlite development cannot see.\n */\nexport function standingRoleQuery(\n repository: Repository<Role>,\n name: string,\n domainId: string\n): SelectQueryBuilder<Role> {\n return repository\n .createQueryBuilder('ROLE')\n .leftJoinAndSelect('ROLE.domain', 'ROLE_DOMAIN')\n .where('ROLE.name = :name', { name })\n .andWhere('ROLE_DOMAIN.id = :domainId', { domainId })\n}\n"]}
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Whether a role may be given a name that is already standing. (Pure rule — ADR-0062 decision 3.)
3
+ *
4
+ * ## The situation
5
+ *
6
+ * A domain sees its own roles and its parent's. Until this rule, the duplicate checks asked only
7
+ * about the domain itself, so a domain with a parent could be given a role whose name was already
8
+ * visible through inheritance. Two rows then answered to one name: every list showed both, and
9
+ * resolving the name picked one. An approval line could end up pointing at the one nobody looks
10
+ * at.
11
+ *
12
+ * ## What is refused, and what is not
13
+ *
14
+ * ```
15
+ * here the domain already has this name always refused
16
+ * inherited the parent has it and this domain not refused unless the operator says so
17
+ * nowhere neither stands
18
+ * ```
19
+ *
20
+ * ## Why the escape hatch does not ask for a different name
21
+ *
22
+ * ⚠ This is the part that is easy to get backwards. ADR-0062 decision 2 sets out the only path
23
+ * for moving a domain off an inherited structure: **stand the role up here → move the people to
24
+ * the role of the same name → cut the parent last.** The name being the same is the point of it
25
+ * — the domain's own role is standing up to *replace* what it inherits, and once the parent is
26
+ * cut there is one name again.
27
+ *
28
+ * So requiring a different name would close the one route decision 2 defines. The escape is the
29
+ * operator saying, in as many words, that this domain stands its own — and the name stays.
30
+ * Asking for a different name remains available on top of that, for when somebody genuinely
31
+ * wants a second seat with the same privileges; it is a choice, never a requirement.
32
+ */
33
+ /** Where a role of this name already stands, seen from the domain that is asking. */
34
+ export type NameStanding = 'nowhere' | 'here' | 'inherited';
35
+ /** The domain asking. `parentId` absent means it inherits from nobody. */
36
+ export type AskingDomain = {
37
+ id: string;
38
+ parentId?: string | null;
39
+ };
40
+ /** A role that was found carrying the name, or nothing. */
41
+ export type StandingRole = {
42
+ domainId?: string | null;
43
+ } | null | undefined;
44
+ /**
45
+ * Where the name stands.
46
+ *
47
+ * A role found in neither this domain nor its parent is `nowhere` — the caller is expected to
48
+ * have looked in those two places, and a row from somewhere else is not this domain's business.
49
+ */
50
+ export declare function nameStands(found: StandingRole, domain: AskingDomain): NameStanding;
51
+ /**
52
+ * Does the name refuse?
53
+ *
54
+ * `separately` is the operator saying this domain stands its own of that name. It reaches only
55
+ * the inherited case — a name already taken **in this domain** is not a thing anybody can opt
56
+ * into, because the two rows would be indistinguishable in every respect.
57
+ */
58
+ export declare function refusesName(standing: NameStanding, separately?: boolean): boolean;