@things-factory/auth-base 10.1.215 → 10.1.223

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 (132) hide show
  1. package/config/config.development.js +2 -4
  2. package/config/config.production.js +1 -2
  3. package/dist-client/actions/auth.d.ts +1 -0
  4. package/dist-client/actions/auth.js +6 -0
  5. package/dist-client/actions/auth.js.map +1 -1
  6. package/dist-client/reducers/auth.d.ts +8 -0
  7. package/dist-client/reducers/auth.js +8 -2
  8. package/dist-client/reducers/auth.js.map +1 -1
  9. package/dist-client/tsconfig.tsbuildinfo +1 -1
  10. package/dist-server/constants/error-code.d.ts +1 -0
  11. package/dist-server/constants/error-code.js +2 -1
  12. package/dist-server/constants/error-code.js.map +1 -1
  13. package/dist-server/controllers/auth.d.ts +0 -1
  14. package/dist-server/controllers/auth.js +0 -1
  15. package/dist-server/controllers/auth.js.map +1 -1
  16. package/dist-server/controllers/password-setup.d.ts +33 -0
  17. package/dist-server/controllers/password-setup.js +77 -0
  18. package/dist-server/controllers/password-setup.js.map +1 -0
  19. package/dist-server/controllers/reset-password.d.ts +8 -1
  20. package/dist-server/controllers/reset-password.js +21 -11
  21. package/dist-server/controllers/reset-password.js.map +1 -1
  22. package/dist-server/controllers/signin.js +10 -0
  23. package/dist-server/controllers/signin.js.map +1 -1
  24. package/dist-server/index.d.ts +1 -0
  25. package/dist-server/index.js +2 -0
  26. package/dist-server/index.js.map +1 -1
  27. package/dist-server/middlewares/jwt-authenticate-middleware.js +8 -25
  28. package/dist-server/middlewares/jwt-authenticate-middleware.js.map +1 -1
  29. package/dist-server/router/auth-public-process-router.js +3 -41
  30. package/dist-server/router/auth-public-process-router.js.map +1 -1
  31. package/dist-server/service/access-request/access-request.js +2 -2
  32. package/dist-server/service/access-request/access-request.js.map +1 -1
  33. package/dist-server/service/audit/audit-kinds.js +1 -0
  34. package/dist-server/service/audit/audit-kinds.js.map +1 -1
  35. package/dist-server/service/audit/audit-subscriber.d.ts +1 -7
  36. package/dist-server/service/audit/audit-subscriber.js +2 -33
  37. package/dist-server/service/audit/audit-subscriber.js.map +1 -1
  38. package/dist-server/service/audit/index.d.ts +1 -1
  39. package/dist-server/service/index.d.ts +2 -4
  40. package/dist-server/service/index.js +22 -27
  41. package/dist-server/service/index.js.map +1 -1
  42. package/dist-server/service/open-fields.js +0 -2
  43. package/dist-server/service/open-fields.js.map +1 -1
  44. package/dist-server/service/role/role-mutation.js +5 -0
  45. package/dist-server/service/role/role-mutation.js.map +1 -1
  46. package/dist-server/service/role/role-types.d.ts +1 -0
  47. package/dist-server/service/role/role-types.js +4 -0
  48. package/dist-server/service/role/role-types.js.map +1 -1
  49. package/dist-server/service/role/role.d.ts +10 -0
  50. package/dist-server/service/role/role.js +5 -0
  51. package/dist-server/service/role/role.js.map +1 -1
  52. package/dist-server/service/role-template/role-template-gap-resolver.d.ts +2 -2
  53. package/dist-server/service/role-template/role-template-gap-resolver.js +7 -13
  54. package/dist-server/service/role-template/role-template-gap-resolver.js.map +1 -1
  55. package/dist-server/service/role-template/role-template-mutation.d.ts +7 -4
  56. package/dist-server/service/role-template/role-template-mutation.js +15 -15
  57. package/dist-server/service/role-template/role-template-mutation.js.map +1 -1
  58. package/dist-server/service/role-template/role-template-types.d.ts +3 -3
  59. package/dist-server/service/role-template/role-template-types.js.map +1 -1
  60. package/dist-server/service/role-template/role-template.d.ts +14 -15
  61. package/dist-server/service/role-template/role-template.js +8 -8
  62. package/dist-server/service/role-template/role-template.js.map +1 -1
  63. package/dist-server/service/user/user-mutation.d.ts +27 -6
  64. package/dist-server/service/user/user-mutation.js +26 -114
  65. package/dist-server/service/user/user-mutation.js.map +1 -1
  66. package/dist-server/service/user/user-query.d.ts +3 -4
  67. package/dist-server/service/user/user-query.js +7 -32
  68. package/dist-server/service/user/user-query.js.map +1 -1
  69. package/dist-server/service/user/user-types.d.ts +6 -2
  70. package/dist-server/service/user/user-types.js +20 -9
  71. package/dist-server/service/user/user-types.js.map +1 -1
  72. package/dist-server/service/user/user.js +17 -8
  73. package/dist-server/service/user/user.js.map +1 -1
  74. package/dist-server/service/verification-token/verification-token.d.ts +1 -0
  75. package/dist-server/service/verification-token/verification-token.js +1 -0
  76. package/dist-server/service/verification-token/verification-token.js.map +1 -1
  77. package/dist-server/templates/reset-password-email.d.ts +10 -3
  78. package/dist-server/templates/reset-password-email.js +8 -4
  79. package/dist-server/templates/reset-password-email.js.map +1 -1
  80. package/dist-server/tsconfig.tsbuildinfo +1 -1
  81. package/dist-server/utils/verification-token-rule.js +3 -1
  82. package/dist-server/utils/verification-token-rule.js.map +1 -1
  83. package/package.json +2 -2
  84. package/tests/add-user-gates.test.ts +34 -0
  85. package/tests/list-inheritance-db.test.ts +0 -15
  86. package/tests/lock-recovery.test.ts +2 -2
  87. package/tests/open-doors.test.ts +2 -5
  88. package/tests/password-reset-rule-db.test.ts +17 -16
  89. package/tests/password-setup-db.test.ts +213 -0
  90. package/tests/picture-reducer.test.ts +25 -0
  91. package/tests/role-template-gap-db.test.ts +58 -7
  92. package/tests/role-template-seed-db.test.ts +5 -4
  93. package/tests/verification-token-db.test.ts +12 -7
  94. package/translations/en.json +2 -2
  95. package/translations/es.json +2 -2
  96. package/translations/ja.json +2 -2
  97. package/translations/ko.json +2 -2
  98. package/translations/ms.json +2 -2
  99. package/translations/zh.json +2 -2
  100. package/dist-server/controllers/invitation.d.ts +0 -75
  101. package/dist-server/controllers/invitation.js +0 -195
  102. package/dist-server/controllers/invitation.js.map +0 -1
  103. package/dist-server/controllers/utils/make-invitation-token.d.ts +0 -1
  104. package/dist-server/controllers/utils/make-invitation-token.js +0 -9
  105. package/dist-server/controllers/utils/make-invitation-token.js.map +0 -1
  106. package/dist-server/controllers/utils/save-invitation-token.d.ts +0 -5
  107. package/dist-server/controllers/utils/save-invitation-token.js +0 -12
  108. package/dist-server/controllers/utils/save-invitation-token.js.map +0 -1
  109. package/dist-server/service/invitation/index.d.ts +0 -5
  110. package/dist-server/service/invitation/index.js +0 -9
  111. package/dist-server/service/invitation/index.js.map +0 -1
  112. package/dist-server/service/invitation/invitation-mutation.d.ts +0 -20
  113. package/dist-server/service/invitation/invitation-mutation.js +0 -109
  114. package/dist-server/service/invitation/invitation-mutation.js.map +0 -1
  115. package/dist-server/service/invitation/invitation-query.d.ts +0 -23
  116. package/dist-server/service/invitation/invitation-query.js +0 -89
  117. package/dist-server/service/invitation/invitation-query.js.map +0 -1
  118. package/dist-server/service/invitation/invitation-types.d.ts +0 -5
  119. package/dist-server/service/invitation/invitation-types.js +0 -21
  120. package/dist-server/service/invitation/invitation-types.js.map +0 -1
  121. package/dist-server/service/invitation/invitation.d.ts +0 -26
  122. package/dist-server/service/invitation/invitation.js +0 -142
  123. package/dist-server/service/invitation/invitation.js.map +0 -1
  124. package/dist-server/templates/invitation-email.d.ts +0 -6
  125. package/dist-server/templates/invitation-email.js +0 -82
  126. package/dist-server/templates/invitation-email.js.map +0 -1
  127. package/dist-server/utils/invitation-state.d.ts +0 -52
  128. package/dist-server/utils/invitation-state.js +0 -76
  129. package/dist-server/utils/invitation-state.js.map +0 -1
  130. package/tests/invitation-db.test.ts +0 -416
  131. package/tests/invitation-route.test.ts +0 -116
  132. package/tests/invitation-state.test.ts +0 -92
@@ -27,7 +27,7 @@ const crypto_1 = require("crypto");
27
27
  * and a rule that reads it would extend a link by touching the row. `expiresAt` is written once,
28
28
  * with the token.
29
29
  *
30
- * A row without `expiresAt` reads as **expired** — the rule `invitation-state.ts` uses. Such rows
30
+ * A row without `expiresAt` reads as **expired**. Such rows
31
31
  * were written before this existed, with the token in plain text; refusing them is also how links
32
32
  * already sent stop working when this ships.
33
33
  *
@@ -39,6 +39,8 @@ const crypto_1 = require("crypto");
39
39
  const HOUR = 60 * 60 * 1000;
40
40
  exports.VERIFICATION_TOKEN_LIFETIME_MS = Object.freeze({
41
41
  'password-reset': 1 * HOUR,
42
+ /* Handed over by mail or by hand, so it lives as long as an invitation did (ADR-0161 decision 3). */
43
+ 'password-setup': 48 * HOUR,
42
44
  unlock: 1 * HOUR,
43
45
  activation: 48 * HOUR
44
46
  });
@@ -1 +1 @@
1
- {"version":3,"file":"verification-token-rule.js","sourceRoot":"","sources":["../../server/utils/verification-token-rule.ts"],"names":[],"mappings":";;;AA2CA,sDAEC;AAQD,gEAQC;AAMD,0DAYC;AA/ED,mCAAmC;AAEnC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,MAAM,IAAI,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAA;AAEd,QAAA,8BAA8B,GAAqC,MAAM,CAAC,MAAM,CAAC;IAC5F,gBAAgB,EAAE,CAAC,GAAG,IAAI;IAC1B,MAAM,EAAE,CAAC,GAAG,IAAI;IAChB,UAAU,EAAE,EAAE,GAAG,IAAI;CACtB,CAAC,CAAA;AAEF,6CAA6C;AAC7C,SAAgB,qBAAqB,CAAC,KAAa;IACjD,OAAO,IAAA,mBAAU,EAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;AACjE,CAAC;AAED;;;;;GAKG;AACH,SAAgB,0BAA0B,CAAC,IAAY,EAAE,MAAY,IAAI,IAAI,EAAE;IAC7E,MAAM,QAAQ,GAAG,sCAA8B,CAAC,IAAI,CAAC,CAAA;IAErD,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,IAAI,KAAK,CAAC,4BAA4B,IAAI,mBAAmB,CAAC,CAAA;IACtE,CAAC;IAED,OAAO,IAAI,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,QAAQ,CAAC,CAAA;AAC3C,CAAC;AAKD,0GAA0G;AAC1G,SAAgB,uBAAuB,CAAC,MAA+B,EAAE,MAAY,IAAI,IAAI,EAAE;IAC7F,IAAI,CAAC,MAAM,EAAE,SAAS,EAAE,CAAC;QACvB,OAAO,KAAK,CAAA;IACd,CAAC;IAED,MAAM,EAAE,GAAG,MAAM,CAAC,SAAS,YAAY,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,CAAA;IAE3F,IAAI,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC;QAC/B,OAAO,KAAK,CAAA;IACd,CAAC;IAED,OAAO,GAAG,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,OAAO,EAAE,CAAA;AACrC,CAAC","sourcesContent":["import { createHash } from 'crypto'\n\n/**\n * How long a mailed link lives, and what the table keeps of it. (Pure rule.)\n *\n * Password reset, unlock and sign-up confirmation each mail a link that carries a token, and the\n * token is the whole credential: whoever holds the link resets the password or unlocks the account.\n * Before 2026-09-24 those tokens never expired, were stored as written and were found by that same\n * value — so a reset mail from last year still changed a password today, and a copy of the table\n * (a backup, a replica, a dump handed to support) was a set of live reset links.\n *\n * ── The lifetimes ──────────────────────────────────────────────────────────\n *\n * password-reset 1 hour hands the account over at once, so short\n * unlock 1 hour the same\n * activation 48 hours the person may read the mail late; it only confirms an address\n *\n * Decided with the architect, 2026-09-24 (`docs/design/auth-rebuild-questions-2026-09-24.md` Q4).\n *\n * ── Expiry is its own column ───────────────────────────────────────────────\n * Not derived from `createdAt` or `updatedAt`: `updatedAt` moves every time the row is saved again,\n * and a rule that reads it would extend a link by touching the row. `expiresAt` is written once,\n * with the token.\n *\n * A row without `expiresAt` reads as **expired** — the rule `invitation-state.ts` uses. Such rows\n * were written before this existed, with the token in plain text; refusing them is also how links\n * already sent stop working when this ships.\n *\n * ── What is stored is a hash ───────────────────────────────────────────────\n * sha256 of the token. The token is 16 random bytes, so there is nothing for a slow hash to protect\n * — it guards against guessing, and there is no guessing a random 128-bit value. The point is only\n * that the table no longer holds anything a link can be made from.\n */\n\nconst HOUR = 60 * 60 * 1000\n\nexport const VERIFICATION_TOKEN_LIFETIME_MS: Readonly<Record<string, number>> = Object.freeze({\n 'password-reset': 1 * HOUR,\n unlock: 1 * HOUR,\n activation: 48 * HOUR\n})\n\n/** The value the table keeps for a token. */\nexport function hashVerificationToken(token: string): string {\n return createHash('sha256').update(token, 'utf8').digest('hex')\n}\n\n/**\n * When a token of this type, issued at `now`, stops working.\n *\n * Throws for a type with no lifetime here — a new kind of mailed link has to say how long it lives\n * rather than inherit forever.\n */\nexport function verificationTokenExpiresAt(type: string, now: Date = new Date()): Date {\n const lifetime = VERIFICATION_TOKEN_LIFETIME_MS[type]\n\n if (!lifetime) {\n throw new Error(`verification token type '${type}' has no lifetime`)\n }\n\n return new Date(now.getTime() + lifetime)\n}\n\n/** The parts of a stored token this rule reads. */\nexport type VerificationTokenRecord = { expiresAt?: Date | string | null } | null | undefined\n\n/** May this token still be used, as of `now`? Absence of an expiry refuses; the expiry itself refuses. */\nexport function isVerificationTokenLive(record: VerificationTokenRecord, now: Date = new Date()): boolean {\n if (!record?.expiresAt) {\n return false\n }\n\n const at = record.expiresAt instanceof Date ? record.expiresAt : new Date(record.expiresAt)\n\n if (Number.isNaN(at.getTime())) {\n return false\n }\n\n return now.getTime() < at.getTime()\n}\n"]}
1
+ {"version":3,"file":"verification-token-rule.js","sourceRoot":"","sources":["../../server/utils/verification-token-rule.ts"],"names":[],"mappings":";;;AA6CA,sDAEC;AAQD,gEAQC;AAMD,0DAYC;AAjFD,mCAAmC;AAEnC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,MAAM,IAAI,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAA;AAEd,QAAA,8BAA8B,GAAqC,MAAM,CAAC,MAAM,CAAC;IAC5F,gBAAgB,EAAE,CAAC,GAAG,IAAI;IAC1B,qGAAqG;IACrG,gBAAgB,EAAE,EAAE,GAAG,IAAI;IAC3B,MAAM,EAAE,CAAC,GAAG,IAAI;IAChB,UAAU,EAAE,EAAE,GAAG,IAAI;CACtB,CAAC,CAAA;AAEF,6CAA6C;AAC7C,SAAgB,qBAAqB,CAAC,KAAa;IACjD,OAAO,IAAA,mBAAU,EAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;AACjE,CAAC;AAED;;;;;GAKG;AACH,SAAgB,0BAA0B,CAAC,IAAY,EAAE,MAAY,IAAI,IAAI,EAAE;IAC7E,MAAM,QAAQ,GAAG,sCAA8B,CAAC,IAAI,CAAC,CAAA;IAErD,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,MAAM,IAAI,KAAK,CAAC,4BAA4B,IAAI,mBAAmB,CAAC,CAAA;IACtE,CAAC;IAED,OAAO,IAAI,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,QAAQ,CAAC,CAAA;AAC3C,CAAC;AAKD,0GAA0G;AAC1G,SAAgB,uBAAuB,CAAC,MAA+B,EAAE,MAAY,IAAI,IAAI,EAAE;IAC7F,IAAI,CAAC,MAAM,EAAE,SAAS,EAAE,CAAC;QACvB,OAAO,KAAK,CAAA;IACd,CAAC;IAED,MAAM,EAAE,GAAG,MAAM,CAAC,SAAS,YAAY,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,CAAA;IAE3F,IAAI,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,EAAE,CAAC;QAC/B,OAAO,KAAK,CAAA;IACd,CAAC;IAED,OAAO,GAAG,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,OAAO,EAAE,CAAA;AACrC,CAAC","sourcesContent":["import { createHash } from 'crypto'\n\n/**\n * How long a mailed link lives, and what the table keeps of it. (Pure rule.)\n *\n * Password reset, unlock and sign-up confirmation each mail a link that carries a token, and the\n * token is the whole credential: whoever holds the link resets the password or unlocks the account.\n * Before 2026-09-24 those tokens never expired, were stored as written and were found by that same\n * value — so a reset mail from last year still changed a password today, and a copy of the table\n * (a backup, a replica, a dump handed to support) was a set of live reset links.\n *\n * ── The lifetimes ──────────────────────────────────────────────────────────\n *\n * password-reset 1 hour hands the account over at once, so short\n * unlock 1 hour the same\n * activation 48 hours the person may read the mail late; it only confirms an address\n *\n * Decided with the architect, 2026-09-24 (`docs/design/auth-rebuild-questions-2026-09-24.md` Q4).\n *\n * ── Expiry is its own column ───────────────────────────────────────────────\n * Not derived from `createdAt` or `updatedAt`: `updatedAt` moves every time the row is saved again,\n * and a rule that reads it would extend a link by touching the row. `expiresAt` is written once,\n * with the token.\n *\n * A row without `expiresAt` reads as **expired**. Such rows\n * were written before this existed, with the token in plain text; refusing them is also how links\n * already sent stop working when this ships.\n *\n * ── What is stored is a hash ───────────────────────────────────────────────\n * sha256 of the token. The token is 16 random bytes, so there is nothing for a slow hash to protect\n * — it guards against guessing, and there is no guessing a random 128-bit value. The point is only\n * that the table no longer holds anything a link can be made from.\n */\n\nconst HOUR = 60 * 60 * 1000\n\nexport const VERIFICATION_TOKEN_LIFETIME_MS: Readonly<Record<string, number>> = Object.freeze({\n 'password-reset': 1 * HOUR,\n /* Handed over by mail or by hand, so it lives as long as an invitation did (ADR-0161 decision 3). */\n 'password-setup': 48 * HOUR,\n unlock: 1 * HOUR,\n activation: 48 * HOUR\n})\n\n/** The value the table keeps for a token. */\nexport function hashVerificationToken(token: string): string {\n return createHash('sha256').update(token, 'utf8').digest('hex')\n}\n\n/**\n * When a token of this type, issued at `now`, stops working.\n *\n * Throws for a type with no lifetime here — a new kind of mailed link has to say how long it lives\n * rather than inherit forever.\n */\nexport function verificationTokenExpiresAt(type: string, now: Date = new Date()): Date {\n const lifetime = VERIFICATION_TOKEN_LIFETIME_MS[type]\n\n if (!lifetime) {\n throw new Error(`verification token type '${type}' has no lifetime`)\n }\n\n return new Date(now.getTime() + lifetime)\n}\n\n/** The parts of a stored token this rule reads. */\nexport type VerificationTokenRecord = { expiresAt?: Date | string | null } | null | undefined\n\n/** May this token still be used, as of `now`? Absence of an expiry refuses; the expiry itself refuses. */\nexport function isVerificationTokenLive(record: VerificationTokenRecord, now: Date = new Date()): boolean {\n if (!record?.expiresAt) {\n return false\n }\n\n const at = record.expiresAt instanceof Date ? record.expiresAt : new Date(record.expiresAt)\n\n if (Number.isNaN(at.getTime())) {\n return false\n }\n\n return now.getTime() < at.getTime()\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/auth-base",
3
- "version": "10.1.215",
3
+ "version": "10.1.223",
4
4
  "main": "dist-server/index.js",
5
5
  "browser": "dist-client/index.js",
6
6
  "things-factory": true,
@@ -50,5 +50,5 @@
50
50
  "passport-jwt": "^4.0.0",
51
51
  "passport-local": "^1.0.0"
52
52
  },
53
- "gitHead": "7de5e80eb1f4e2e268746aee39f820e7563f454d"
53
+ "gitHead": "868f2026b8373371ad04f42b0a097964d0d0ed0f"
54
54
  }
@@ -0,0 +1,34 @@
1
+ import { buildSchema } from 'type-graphql'
2
+
3
+ import { loadCompiled } from './compiled'
4
+
5
+ /**
6
+ * The questions the user popups ask are gated as the actions they lead to.
7
+ *
8
+ * `checkUserExistence` is asked only by the add-user popup, before `inviteUser` / `createUser`
9
+ * (each `user:mutation`, owner granted). It asked `user:query` alone, so a domain owner without that role was refused
10
+ * the lookup and the popup took the refusal for "no account" - and set out to create an account for someone who
11
+ * had one. `mayIssuePasswordSetupLink` answers for the setup link button, so it stands where `issuePasswordSetupLink` does.
12
+ */
13
+ const { schema: declared } = loadCompiled('service/index')
14
+ const { privilegeDirectiveResolver, privilegeOfField } = loadCompiled('service/privilege/privilege-directive')
15
+
16
+ function gateOf(field: string) {
17
+ const gate = privilegeOfField('Query', field)
18
+ return [gate.known, gate['gate']?.category, gate['gate']?.privilege, gate['gate']?.owner]
19
+ }
20
+
21
+ describe('what the user popups ask', () => {
22
+ beforeAll(async () => {
23
+ const schema = await buildSchema({ resolvers: declared.resolverClasses, validate: false })
24
+ privilegeDirectiveResolver(schema, { modules: [] })
25
+ })
26
+
27
+ it('★ checkUserExistence asks what adding a user asks - user:mutation, or the owner', () => {
28
+ expect(gateOf('checkUserExistence')).toEqual([true, 'user', 'mutation', true])
29
+ })
30
+
31
+ it('mayIssuePasswordSetupLink asks what issuing asks', () => {
32
+ expect(gateOf('mayIssuePasswordSetupLink')).toEqual([true, 'user', 'mutation', true])
33
+ })
34
+ })
@@ -19,9 +19,6 @@ const { Appliance } = loadCompiled('service/appliance/appliance')
19
19
  const { ApplianceQuery } = loadCompiled('service/appliance/appliance-query')
20
20
  const { Application } = loadCompiled('service/application/application')
21
21
  const { ApplicationQuery } = loadCompiled('service/application/application-query')
22
- const { Invitation } = loadCompiled('service/invitation/invitation')
23
- const { InvitationStatus } = loadCompiled('utils/invitation-state')
24
- const { InvitationQuery } = loadCompiled('service/invitation/invitation-query')
25
22
  const { Partner } = loadCompiled('service/partner/partner')
26
23
  const { PartnerQuery } = loadCompiled('service/partner/partner-query')
27
24
 
@@ -101,18 +98,6 @@ const LISTS: [string, () => Promise<any>, (params: any, context: any) => Promise
101
98
  }),
102
99
  (params, context) => new ApplicationQuery().applications(params, context)
103
100
  ],
104
- [
105
- 'invitations',
106
- () =>
107
- getRepository(Invitation).save({
108
- email: 'lee@acme.z',
109
- domain: parent,
110
- status: InvitationStatus.PENDING,
111
- token: 'parent-token',
112
- expiresAt: new Date(Date.now() + 3600_000)
113
- }),
114
- (params, context) => new InvitationQuery().invitations(params, context)
115
- ],
116
101
  [
117
102
  'partners',
118
103
  () => getRepository(Partner).save({ domain: parent, partnerDomain, requestedAt: new Date() }),
@@ -70,7 +70,7 @@ describe('재설정 메일 경로 (PASSWORD_RESET)', () => {
70
70
  const user = await lockedUser()
71
71
  await giveToken(user.id, 'tok', VerificationTokenType.PASSWORD_RESET)
72
72
 
73
- await resetPassword('tok', NEW, context)
73
+ await resetPassword('tok', NEW, 'kim@example.com', context)
74
74
 
75
75
  const after = await getRepository(User).findOneBy({ id: user.id })
76
76
  expect(User.verify(after.password, NEW, after.salt)).toBe(true)
@@ -84,7 +84,7 @@ describe('재설정 메일 경로 (PASSWORD_RESET)', () => {
84
84
  const user = await lockedUser()
85
85
  await giveToken(user.id, 'tok', VerificationTokenType.PASSWORD_RESET)
86
86
 
87
- await resetPassword('tok', NEW, context)
87
+ await resetPassword('tok', NEW, 'kim@example.com', context)
88
88
 
89
89
  await expect(signin({ username: 'kim', password: NEW })).rejects.toThrow()
90
90
  })
@@ -81,8 +81,7 @@ describe('the doors that were closed', () => {
81
81
  * domainUserRoleRegister makes a domain, its roles and its users. `domainRegister`
82
82
  * beside it makes only a domain and is super-user only
83
83
  * loginHistories everybody's sign-ins - now the audit's, behind `audit:query` (ADR-0141)
84
- * invitation / invitations and neither of them scopes by tenant at all
85
- * sendInvitation / cancelInvitation / inviteUser
84
+ * inviteUser
86
85
  */
87
86
  const CLOSED = {
88
87
  Mutation: [
@@ -92,11 +91,9 @@ describe('the doors that were closed', () => {
92
91
  'deleteAuthProvider',
93
92
  'deleteAuthProviders',
94
93
  'domainUserRoleRegister',
95
- 'sendInvitation',
96
- 'cancelInvitation',
97
94
  'inviteUser'
98
95
  ],
99
- Query: ['auditRecords', 'auditPeople', 'auditDoors', 'auditHealth', 'invitation', 'invitations']
96
+ Query: ['auditRecords', 'auditPeople', 'auditDoors', 'auditHealth']
100
97
  }
101
98
 
102
99
  it.each(Object.entries(CLOSED).flatMap(([t, fields]) => fields.map(f => [t, f])))('%s.%s', (typeName, field) => {
@@ -1,14 +1,12 @@
1
1
  /**
2
- * Whom a domain may put back on the default password.
2
+ * Whose password a domain may take back - by issuing a password setup link (ADR-0161).
3
3
  *
4
- * `resetPasswordToDefault` asked only that the person belong to the asking domain. A person has one password for
5
- * every domain, and the default is known: whoever signs in with it first is sent to set a new one. So a user manager
6
- * of any domain could take the account of someone who also works elsewhere, of another domain's owner, and of the
7
- * super user (the system domain's owner) the moment that person was a member here.
4
+ * The reset asked only that the person belong to the asking domain. A person has one password for every domain, so a
5
+ * user manager of any domain could take the password of someone who also works elsewhere, of another domain's owner,
6
+ * and of the super user (the system domain's owner) the moment that person was a member here. It was the default
7
+ * password then; it is a setup link now, and the same people are refused.
8
8
  */
9
9
 
10
- import { config } from '@things-factory/env'
11
-
12
10
  import { closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
13
11
  import { loadCompiled } from './compiled'
14
12
 
@@ -17,7 +15,6 @@ const { UserMutation } = loadCompiled('service/user/user-mutation')
17
15
  const { UserQuery } = loadCompiled('service/user/user-query')
18
16
  const { DomainOwner } = loadCompiled('service/domain-owner/domain-owner')
19
17
 
20
- const DEFAULT_PASSWORD = 'Default!234'
21
18
  const OWN_PASSWORD = 'Mine!5678'
22
19
 
23
20
  let here: any
@@ -40,15 +37,20 @@ async function makeUser(domains: any[], extra: any = {}) {
40
37
  }
41
38
 
42
39
  function context(domain: any) {
43
- return { state: { domain, tx: undefined }, t: (key: string) => key } as any
40
+ return {
41
+ state: { domain, tx: undefined },
42
+ t: (key: string) => key,
43
+ headers: {},
44
+ request: { protocol: 'https', host: 'acme.example.com' }
45
+ } as any
44
46
  }
45
47
 
46
48
  function reset(user: any, domain: any) {
47
- return new UserMutation().resetPasswordToDefault(user.username, context(domain))
49
+ return new UserMutation().issuePasswordSetupLink(user.username, false, context(domain))
48
50
  }
49
51
 
50
52
  function mayReset(user: any, domain: any) {
51
- return new UserQuery().mayResetPasswordToDefault(user.id, context(domain))
53
+ return new UserQuery().mayIssuePasswordSetupLink(user.id, context(domain))
52
54
  }
53
55
 
54
56
  async function stillHasOwnPassword(user: any) {
@@ -61,13 +63,11 @@ beforeAll(async () => {
61
63
  })
62
64
 
63
65
  afterAll(async () => {
64
- config.set('password', undefined)
65
66
  await closeAuthDatabase()
66
67
  })
67
68
 
68
69
  beforeEach(async () => {
69
70
  await resetAuthDatabase()
70
- config.set('password', { defaultPassword: DEFAULT_PASSWORD })
71
71
 
72
72
  const domainRepository = getRepository(Domain)
73
73
  here = await domainRepository.save({ name: 'Plant A', subdomain: 'plant-a' })
@@ -76,14 +76,15 @@ beforeEach(async () => {
76
76
  })
77
77
 
78
78
  describe('a person wholly of this domain', () => {
79
- it('is reset to the default, and the screen offers it', async () => {
79
+ it('is issued a link and waits for a password, and the screen offers it', async () => {
80
80
  const user = await makeUser([here])
81
81
 
82
82
  expect(await mayReset(user, here)).toBe(true)
83
- await expect(reset(user, here)).resolves.toBe(true)
83
+ await expect(reset(user, here)).resolves.toMatchObject({ link: expect.stringContaining('/auth/reset-password#') })
84
84
 
85
85
  const read = await getRepository(User).findOneBy({ id: user.id })
86
- expect(User.verify(read.password, DEFAULT_PASSWORD, read.salt)).toBe(true)
86
+ expect(read.status).toBe('password_reset_required')
87
+ expect(await stillHasOwnPassword(user)).toBe(false)
87
88
  })
88
89
  })
89
90
 
@@ -0,0 +1,213 @@
1
+ /**
2
+ * An administrator never sets anybody's password (ADR-0161).
3
+ *
4
+ * It used to be the installation's default password - one value everyone handed one knew, with no end, for every
5
+ * account an administrator made or reset. Now such an account waits for its password, and the person sets it through
6
+ * a one-time link that is mailed or handed over: 48 hours, hashed at rest, asking for the address it was for, and the
7
+ * only live one. Issuing it ends the sessions opened before.
8
+ */
9
+
10
+ import { authDataSource, closeAuthDatabase, Domain, getRepository, openAuthDatabase, resetAuthDatabase } from './db'
11
+ import { loadCompiled } from './compiled'
12
+
13
+ const jwt = require('jsonwebtoken')
14
+
15
+ const { User } = loadCompiled('service/user/user')
16
+ const { UserMutation } = loadCompiled('service/user/user-mutation')
17
+ const { VerificationToken } = loadCompiled('service/verification-token/verification-token')
18
+ const { signin } = loadCompiled('controllers/signin')
19
+ const { resetPassword } = loadCompiled('controllers/reset-password')
20
+ const { hashVerificationToken } = loadCompiled('utils/verification-token-rule')
21
+
22
+ const OWN_PASSWORD = 'Mine!5678'
23
+ const NEW_PASSWORD = 'N3w!Passw0rd'
24
+ const HOUR = 60 * 60 * 1000
25
+
26
+ let here: any
27
+
28
+ function context(tx?: any) {
29
+ return {
30
+ state: { domain: here, tx },
31
+ t: (key: string) => key,
32
+ headers: {},
33
+ request: { protocol: 'https', host: 'acme.example.com' }
34
+ } as any
35
+ }
36
+
37
+ async function makeActive(email = 'kim@example.com') {
38
+ const salt = User.generateSalt()
39
+ return await getRepository(User).save({
40
+ username: email,
41
+ email,
42
+ name: 'Kim',
43
+ salt,
44
+ password: User.encode(OWN_PASSWORD, salt),
45
+ status: 'activated',
46
+ domains: [here]
47
+ })
48
+ }
49
+
50
+ function create(email = 'lee@example.com') {
51
+ return new UserMutation().createUser({ username: email, email, name: 'Lee', userType: 'user' }, context())
52
+ }
53
+
54
+ function issue(user: any, mail = false, tx?: any) {
55
+ return new UserMutation().issuePasswordSetupLink(user.username, mail, context(tx))
56
+ }
57
+
58
+ const tokenOf = (link: string) => link.split('#')[1]
59
+ const reload = (id: string) => getRepository(User).findOneBy({ id })
60
+ const setPassword = (token: string, email: string, password = NEW_PASSWORD) =>
61
+ resetPassword(token, password, email, { t: (key: string) => key })
62
+
63
+ beforeAll(async () => {
64
+ await openAuthDatabase()
65
+ })
66
+
67
+ afterAll(async () => {
68
+ await closeAuthDatabase()
69
+ })
70
+
71
+ beforeEach(async () => {
72
+ await resetAuthDatabase()
73
+ here = await getRepository(Domain).save({ name: 'Plant A', subdomain: 'plant-a' })
74
+ })
75
+
76
+ describe('an account an administrator makes', () => {
77
+ it('★ waits for its password, which nobody was given', async () => {
78
+ const made = await create()
79
+ const read = await reload(made.id)
80
+
81
+ expect(read.status).toBe('password_reset_required')
82
+ expect(read.password).toBeTruthy()
83
+ })
84
+
85
+ it('★ does not sign in, and a guess against it is not counted', async () => {
86
+ const made = await create()
87
+
88
+ await expect(signin({ username: made.username, password: 'anything' })).rejects.toMatchObject({
89
+ errorCode: 'password-setup-pending'
90
+ })
91
+ expect((await reload(made.id)).failCount ?? 0).toBe(0)
92
+ })
93
+
94
+ it('★ is refused on every request too', async () => {
95
+ const made = await create()
96
+
97
+ await expect(User.checkAuth({ username: made.username, email: made.email })).rejects.toMatchObject({
98
+ errorCode: 'password-setup-pending'
99
+ })
100
+ })
101
+
102
+ it('is not opened by activating it', async () => {
103
+ const made = await create()
104
+
105
+ await expect(new UserMutation().activateUser(made.username, context())).rejects.toMatchObject({
106
+ code: 'password-setup-pending'
107
+ })
108
+ })
109
+ })
110
+
111
+ describe('the setup link', () => {
112
+ it('★ is handed back once, kept only as a hash, and lives 48 hours', async () => {
113
+ const made = await create()
114
+ const before = Date.now()
115
+
116
+ const issued = await issue(made)
117
+
118
+ expect(issued.link).toMatch(/^https?:\/\/acme\.example\.com\/auth\/reset-password#[0-9a-f]{32}$/)
119
+ const stored = await getRepository(VerificationToken).findOneBy({ userId: made.id })
120
+ expect(stored.type).toBe('password-setup')
121
+ expect(stored.token).toBe(hashVerificationToken(tokenOf(issued.link)))
122
+ expect(stored.token).not.toBe(tokenOf(issued.link))
123
+ expect(new Date(stored.expiresAt).getTime() - before).toBeGreaterThanOrEqual(48 * HOUR - 1000)
124
+ expect(new Date(stored.expiresAt).getTime() - before).toBeLessThanOrEqual(48 * HOUR + 5000)
125
+ })
126
+
127
+ it('★ opens the account with the address it was for, and then signs in', async () => {
128
+ const made = await create()
129
+ const { link } = await issue(made)
130
+
131
+ await setPassword(tokenOf(link), 'LEE@example.com ')
132
+
133
+ expect((await reload(made.id)).status).toBe('activated')
134
+ await expect(signin({ username: made.username, password: NEW_PASSWORD })).resolves.toMatchObject({
135
+ token: expect.any(String)
136
+ })
137
+ })
138
+
139
+ it('★ is not enough without the address it was for', async () => {
140
+ const made = await create()
141
+ const { link } = await issue(made)
142
+
143
+ await expect(setPassword(tokenOf(link), 'someone@else.example')).rejects.toThrow()
144
+ await expect(setPassword(tokenOf(link), '')).rejects.toThrow()
145
+ expect((await reload(made.id)).status).toBe('password_reset_required')
146
+ })
147
+
148
+ it('works once', async () => {
149
+ const made = await create()
150
+ const { link } = await issue(made)
151
+
152
+ await setPassword(tokenOf(link), made.email)
153
+ await expect(setPassword(tokenOf(link), made.email, 'An0ther!Pass')).rejects.toThrow()
154
+ })
155
+
156
+ it('★ issued again, retires the last one', async () => {
157
+ const made = await create()
158
+ const first = await issue(made)
159
+ const second = await issue(made)
160
+
161
+ await expect(setPassword(tokenOf(first.link), made.email)).rejects.toThrow()
162
+ await setPassword(tokenOf(second.link), made.email)
163
+ expect((await reload(made.id)).status).toBe('activated')
164
+ })
165
+ })
166
+
167
+ describe('taking back the password of an account in use', () => {
168
+ it('★ stops the old password and ends the sessions opened before', async () => {
169
+ const kim = await makeActive()
170
+ const { token: before } = await signin({ username: kim.username, password: OWN_PASSWORD })
171
+ await expect(User.checkAuth(jwt.decode(before))).resolves.toBeTruthy()
172
+
173
+ const { link } = await issue(kim)
174
+
175
+ await expect(signin({ username: kim.username, password: OWN_PASSWORD })).rejects.toMatchObject({
176
+ errorCode: 'password-setup-pending'
177
+ })
178
+
179
+ await setPassword(tokenOf(link), kim.email)
180
+
181
+ /* Open again - but the session from before stays ended: its generation has passed. */
182
+ await expect(User.checkAuth(jwt.decode(before))).rejects.toThrow()
183
+ const { token: after } = await signin({ username: kim.username, password: NEW_PASSWORD })
184
+ await expect(User.checkAuth(jwt.decode(after))).resolves.toBeTruthy()
185
+ })
186
+
187
+ it('★ by mail - a mail that does not go out changes nothing', async () => {
188
+ const kim = await makeActive()
189
+
190
+ await expect(
191
+ authDataSource().transaction(async (tx: any) => await issue(kim, true, tx))
192
+ ).rejects.toMatchObject({ code: 'password-setup-mail-not-sent' })
193
+
194
+ const read = await reload(kim.id)
195
+ expect(read.status).toBe('activated')
196
+ expect(User.verify(read.password, OWN_PASSWORD, read.salt)).toBe(true)
197
+ expect(await getRepository(VerificationToken).findOneBy({ userId: kim.id })).toBeNull()
198
+ })
199
+ })
200
+
201
+ describe('a forgotten password', () => {
202
+ it('asks for the address as well, through the same page', async () => {
203
+ const kim = await makeActive()
204
+ const { makeVerificationToken } = loadCompiled('controllers/utils/make-verification-token')
205
+ const { saveVerificationToken } = loadCompiled('controllers/utils/save-verification-token')
206
+ const token = makeVerificationToken()
207
+ await saveVerificationToken(kim.id, token, 'password-reset')
208
+
209
+ await expect(setPassword(token, 'someone@else.example')).rejects.toThrow()
210
+ await setPassword(token, kim.email)
211
+ await expect(signin({ username: kim.username, password: NEW_PASSWORD })).resolves.toBeTruthy()
212
+ })
213
+ })
@@ -0,0 +1,25 @@
1
+ import { SET_PICTURE, SET_PROFILE } from '../client/actions/auth'
2
+ import auth from '../client/reducers/auth'
3
+
4
+ /** The signed-in person's picture, which the header's avatar draws (`auth.picture`). */
5
+ describe('the signed-in person\'s picture', () => {
6
+ const picture = { picture: '/attachment/face.png', left: 0, top: 0, zoom: 1 }
7
+
8
+ it('starts with none - the avatar draws a monogram', () => {
9
+ expect(auth(undefined, { type: '@@INIT' }).picture).toBeNull()
10
+ })
11
+
12
+ it('★ is set by SET_PICTURE and survives a later profile', () => {
13
+ let state = auth(undefined, { type: SET_PICTURE, picture })
14
+ state = auth(state, { type: SET_PROFILE, user: { name: '남상혁' } })
15
+
16
+ expect(state.picture).toEqual(picture)
17
+ expect(state.user).toEqual({ name: '남상혁' })
18
+ })
19
+
20
+ it('a missing picture clears it', () => {
21
+ const state = auth(auth(undefined, { type: SET_PICTURE, picture }), { type: SET_PICTURE, picture: undefined })
22
+
23
+ expect(state.picture).toBeNull()
24
+ })
25
+ })
@@ -1,5 +1,6 @@
1
1
  /**
2
- * What a role lacks against the template of its name - against a real database (ADR-0075 decision 1).
2
+ * What a role lacks against its base template - against a real database (ADR-0075 decision 1, amended 2026-10-26:
3
+ * a role keeps one base template, `Role.templateId`, and is never paired by name).
3
4
  *
4
5
  * A template is a seed and nothing pushes its later grants into roles already standing. When
5
6
  * operato-plant's templates gained ai-assistant, the roles in miratek stayed without it and only
@@ -14,12 +15,16 @@ const { Role } = loadCompiled('service/role/role')
14
15
  const { Privilege } = loadCompiled('service/privilege/privilege')
15
16
  const { User } = loadCompiled('service/user/user')
16
17
  const { RoleTemplateMutation } = loadCompiled('service/role-template/role-template-mutation')
18
+ const { RoleMutation } = loadCompiled('service/role/role-mutation')
17
19
  const { RoleTemplateGapResolver } = loadCompiled('service/role-template/role-template-gap-resolver')
18
20
  const { registerRoleTemplate } = loadCompiled('service/role-template/role-template')
19
21
  const { registerRoleTask } = loadCompiled('service/role-template/role-task')
20
22
 
21
23
  const mutation = new RoleTemplateMutation()
22
- const gapOf = (role: any, templateId?: string) => new RoleTemplateGapResolver().templateGap(role, templateId)
24
+ const gapOf = (role: any) => new RoleTemplateGapResolver().templateGap(role)
25
+ const reread = (role: any) => getRepository(Role).findOneBy({ id: role.id })
26
+ /* The role screen's base template field saves through `updateRole`. */
27
+ const baseOn = (role: any, templateId: string | null) => new RoleMutation().updateRole(role.id, { templateId } as any, { ...context(), t: (key: string) => key } as any)
23
28
  const coverageOf = (role: any) => new RoleTemplateGapResolver().taskCoverage(role)
24
29
 
25
30
  let domain: any
@@ -107,13 +112,52 @@ describe('the gap between a role and its template', () => {
107
112
  expect((await gapOf(role)).missingCount).toBe(0)
108
113
  })
109
114
 
110
- it('★ is null for a role no template names - a renamed role belongs to its administrator', async () => {
115
+ it('★ a role made under a name of its own is still based on the template', async () => {
111
116
  declareTemplate(['query'])
112
117
  const role = await mutation.createRoleFromTemplate('plant.qi', 'NIGHT-INSPECTOR', context())
118
+ declareTemplate(['query', 'mutation'])
119
+
120
+ expect((await reread(role)).templateId).toBe('plant.qi')
121
+ expect(await gapOf(await reread(role))).toMatchObject({ templateId: 'plant.qi', missingCount: 1 })
122
+ })
123
+
124
+ it('★ is null for a role that is based on no template, even when a template carries its name', async () => {
125
+ declareTemplate(['query'])
126
+ const role = await getRepository(Role).save({ domain, name: 'QUALITY-INSPECTOR', privileges: [] })
113
127
 
114
128
  expect(await gapOf(role)).toBeNull()
115
129
  })
116
130
 
131
+ it('★ refuses to top up a role based on another template, and changes nothing', async () => {
132
+ declareTemplate(['query'])
133
+ registerRoleTemplate({ id: 'plant.qm', name: 'QUALITY-MANAGER', grants: [{ category: 'board', axes: ['query', 'mutation'] }] })
134
+ const role = await mutation.createRoleFromTemplate('plant.qi', undefined, context())
135
+
136
+ await expect(mutation.createRoleFromTemplate('plant.qm', 'QUALITY-INSPECTOR', context())).rejects.toThrow(/based on role template "plant.qi", not "plant.qm"/)
137
+
138
+ const stored = await getRepository(Role).findOne({ where: { id: role.id }, relations: ['privileges'] })
139
+ expect(stored.templateId).toBe('plant.qi')
140
+ expect(stored.privileges.map((p: any) => p.name)).toEqual(['query'])
141
+ })
142
+
143
+ it('★ tops up a standing role that has no base and makes the template its base', async () => {
144
+ declareTemplate(['query', 'mutation'])
145
+ const role = await getRepository(Role).save({ domain, name: 'QUALITY-INSPECTOR', privileges: [] })
146
+
147
+ await mutation.createRoleFromTemplate('plant.qi', undefined, context())
148
+
149
+ expect((await reread(role)).templateId).toBe('plant.qi')
150
+ expect((await gapOf(await reread(role))).missingCount).toBe(0)
151
+ })
152
+
153
+ it('★ is null when the base is a template this installation no longer declares', async () => {
154
+ declareTemplate(['query'])
155
+ const role = await mutation.createRoleFromTemplate('plant.qi', undefined, context())
156
+ ;(process as any)['ROLE_TEMPLATES'] = {}
157
+
158
+ expect(await gapOf(await reread(role))).toBeNull()
159
+ })
160
+
117
161
  it('does not take away what the administrator added beyond the template', async () => {
118
162
  declareTemplate(['query'])
119
163
  const role = await mutation.createRoleFromTemplate('plant.qi', undefined, context())
@@ -131,19 +175,26 @@ describe('the gap between a role and its template', () => {
131
175
  expect(gap.extraCount).toBe(1)
132
176
  })
133
177
 
134
- it('★ lays a role against a template the administrator picks, whatever the role is called', async () => {
178
+ it('★ lays a role named in the site\'s own words against the base the administrator gives it', async () => {
135
179
  declareTemplate(['query', 'mutation'])
136
180
  const role = await getRepository(Role).save({ domain, name: '품질검사', privileges: [await getRepository(Privilege).findOneBy({ category: 'board', name: 'query' })] })
137
181
 
138
182
  expect(await gapOf(role)).toBeNull()
139
- expect(await gapOf(role, 'plant.qi')).toMatchObject({ templateId: 'plant.qi', missing: [{ category: 'board', axes: ['mutation'] }], extraCount: 0 })
183
+
184
+ await baseOn(role, 'plant.qi')
185
+ expect(await gapOf(await reread(role))).toMatchObject({ templateId: 'plant.qi', missing: [{ category: 'board', axes: ['mutation'] }], extraCount: 0 })
186
+
187
+ await baseOn(role, null)
188
+ expect((await reread(role)).templateId).toBeNull()
189
+ expect(await gapOf(await reread(role))).toBeNull()
140
190
  })
141
191
 
142
- it('refuses a template id that does not exist rather than answering null', async () => {
192
+ it('refuses a base template id that does not exist rather than storing it', async () => {
143
193
  declareTemplate(['query'])
144
194
  const role = await mutation.createRoleFromTemplate('plant.qi', undefined, context())
145
195
 
146
- await expect(gapOf(role, 'plant.nobody')).rejects.toThrow(/no role template "plant.nobody"/)
196
+ await expect(baseOn(role, 'plant.nobody')).rejects.toThrow(/no role template "plant.nobody"/)
197
+ expect((await reread(role)).templateId).toBe('plant.qi')
147
198
  })
148
199
  })
149
200
 
@@ -119,7 +119,8 @@ describe('seeding', () => {
119
119
  expect(await getRepository(Role).count()).toBe(1)
120
120
  })
121
121
 
122
- it('adds the union when two applications declare the same seat', async () => {
122
+ /* A role has one base template (ADR-0075 decision 1, amended 2026-10-26) - two apps declaring one name no longer union. */
123
+ it('refuses a second template onto a role of the same name based on the first', async () => {
123
124
  registerRoleTemplate({ id: 'plant.qi', name: 'QUALITY-INSPECTOR', grants: [{ category: 'board', axes: ['query'] }] })
124
125
  registerRoleTemplate({
125
126
  id: 'twin.qi',
@@ -127,10 +128,10 @@ describe('seeding', () => {
127
128
  grants: [{ category: 'board', axes: ['mutation'] }]
128
129
  })
129
130
 
130
- await mutation.createRoleFromTemplate('plant.qi', undefined, context())
131
- const role = await mutation.createRoleFromTemplate('twin.qi', undefined, context())
131
+ const role = await mutation.createRoleFromTemplate('plant.qi', undefined, context())
132
+ await expect(mutation.createRoleFromTemplate('twin.qi', undefined, context())).rejects.toThrow(/based on role template "plant.qi", not "twin.qi"/)
132
133
 
133
- expect(await privilegesOf(role.id)).toEqual(['board:mutation', 'board:query'])
134
+ expect(await privilegesOf(role.id)).toEqual(['board:query'])
134
135
  expect(await getRepository(Role).count()).toBe(1)
135
136
  })
136
137