@things-factory/auth-base 10.1.6 → 10.1.16

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 (46) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/dist-client/tsconfig.tsbuildinfo +1 -1
  3. package/dist-server/index.js +16 -0
  4. package/dist-server/index.js.map +1 -1
  5. package/dist-server/router/auth-private-process-router.js +20 -1
  6. package/dist-server/router/auth-private-process-router.js.map +1 -1
  7. package/dist-server/service/index.d.ts +3 -0
  8. package/dist-server/service/index.js +16 -11
  9. package/dist-server/service/index.js.map +1 -1
  10. package/dist-server/service/privilege/privilege-axis.d.ts +72 -0
  11. package/dist-server/service/privilege/privilege-axis.js +188 -0
  12. package/dist-server/service/privilege/privilege-axis.js.map +1 -0
  13. package/dist-server/service/privilege/privilege-directive.d.ts +10 -41
  14. package/dist-server/service/privilege/privilege-directive.js +62 -74
  15. package/dist-server/service/privilege/privilege-directive.js.map +1 -1
  16. package/dist-server/service/role/role-mutation.js +2 -2
  17. package/dist-server/service/role/role-mutation.js.map +1 -1
  18. package/dist-server/service/role/role-query.d.ts +43 -1
  19. package/dist-server/service/role/role-query.js +76 -33
  20. package/dist-server/service/role/role-query.js.map +1 -1
  21. package/dist-server/service/role-template/index.d.ts +4 -0
  22. package/dist-server/service/role-template/index.js +9 -0
  23. package/dist-server/service/role-template/index.js.map +1 -0
  24. package/dist-server/service/role-template/role-template-mutation.d.ts +39 -0
  25. package/dist-server/service/role-template/role-template-mutation.js +124 -0
  26. package/dist-server/service/role-template/role-template-mutation.js.map +1 -0
  27. package/dist-server/service/role-template/role-template-query.d.ts +11 -0
  28. package/dist-server/service/role-template/role-template-query.js +79 -0
  29. package/dist-server/service/role-template/role-template-query.js.map +1 -0
  30. package/dist-server/service/role-template/role-template-types.d.ts +51 -0
  31. package/dist-server/service/role-template/role-template-types.js +71 -0
  32. package/dist-server/service/role-template/role-template-types.js.map +1 -0
  33. package/dist-server/service/role-template/role-template.d.ts +110 -0
  34. package/dist-server/service/role-template/role-template.js +147 -0
  35. package/dist-server/service/role-template/role-template.js.map +1 -0
  36. package/dist-server/tsconfig.tsbuildinfo +1 -1
  37. package/dist-server/utils/check-permission.js +11 -2
  38. package/dist-server/utils/check-permission.js.map +1 -1
  39. package/package.json +4 -4
  40. package/tests/domain-inheritance-sentinel.test.ts +93 -0
  41. package/tests/permission-gate.test.ts +16 -12
  42. package/tests/privilege-axis.test.ts +157 -0
  43. package/tests/privilege-directive.test.ts +70 -81
  44. package/tests/role-privileges-db.test.ts +98 -0
  45. package/tests/role-template-seed-db.test.ts +179 -0
  46. package/tests/role-template.test.ts +162 -0
@@ -15,8 +15,17 @@ async function checkPermission(privilegeObject, user, domain, unsafeIP, prohibit
15
15
  }
16
16
  return await user_js_1.User.hasPrivilege(privilege, category, domain, user);
17
17
  }
18
- // privilege, category가 설정되지 않은 경우에는 ownership granted가 설정되었다면 허가하지 않는다.
19
- return !domainOwnerGranted && !superUserGranted;
18
+ /*
19
+ * Ownership does not count from an unsafe address, so a declaration that rests on
20
+ * ownership alone has nothing left to stand on here and the answer is no.
21
+ *
22
+ * This read `!domainOwnerGranted && !superUserGranted` until 2026-09-15, which answered
23
+ * **yes** for an object that named nothing at all — the same `{}` a safe address
24
+ * refused. An unsafe address was the more permissive of the two, which is backwards.
25
+ * `privilege-directive.ts` now refuses to build a declaration that names nothing, but
26
+ * this function is exported and called directly, so the rule is fixed here too.
27
+ */
28
+ return false;
20
29
  }
21
30
  else {
22
31
  if (!privilege || !category) {
@@ -1 +1 @@
1
- {"version":3,"file":"check-permission.js","sourceRoot":"","sources":["../../server/utils/check-permission.ts"],"names":[],"mappings":";;AAIA,0CA+CC;AAjDD,qDAA8C;AAEvC,KAAK,UAAU,eAAe,CACnC,eAAgC,EAChC,IAAU,EACV,MAAc,EACd,QAAkB,EAClB,oBAAgE;IAEhE,IAAI,CAAC,eAAe,EAAE,CAAC;QACrB,OAAO,IAAI,CAAA;IACb,CAAC;IAED,MAAM,EAAE,KAAK,EAAE,kBAAkB,EAAE,KAAK,EAAE,gBAAgB,EAAE,QAAQ,EAAE,SAAS,EAAE,GAAG,eAAe,CAAA;IAEnG,IAAI,QAAQ,EAAE,CAAC;QACb,IAAI,SAAS,IAAI,QAAQ,EAAE,CAAC;YAC1B,8CAA8C;YAC9C,IAAI,CAAC,oBAAoB,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,QAAQ,IAAI,QAAQ,IAAI,EAAE,CAAC,SAAS,IAAI,SAAS,CAAC,EAAE,CAAC;gBAClG,OAAO,KAAK,CAAA;YACd,CAAC;YAED,OAAO,MAAM,cAAI,CAAC,YAAY,CAAC,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,CAAA;QACnE,CAAC;QAED,wEAAwE;QACxE,OAAO,CAAC,kBAAkB,IAAI,CAAC,gBAAgB,CAAA;IACjD,CAAC;SAAM,CAAC;QACN,IAAI,CAAC,SAAS,IAAI,CAAC,QAAQ,EAAE,CAAC;YAC5B,8DAA8D;YAC9D,OAAO,CACL,CAAC,kBAAkB,IAAI,CAAC,MAAM,OAAO,CAAC,kBAAkB,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC;gBACxE,CAAC,gBAAgB,IAAI,CAAC,MAAM,OAAO,CAAC,gBAAgB,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC,CACrE,CAAA;QACH,CAAC;QAED,IACE,CAAC,kBAAkB,IAAI,CAAC,MAAM,OAAO,CAAC,kBAAkB,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC;YACxE,CAAC,gBAAgB,IAAI,CAAC,MAAM,OAAO,CAAC,gBAAgB,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC,EACpE,CAAC;YACD,OAAO,IAAI,CAAA;QACb,CAAC;QAED,IAAI,CAAC,oBAAoB,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,QAAQ,IAAI,QAAQ,IAAI,EAAE,CAAC,SAAS,IAAI,SAAS,CAAC,EAAE,CAAC;YAClG,OAAO,KAAK,CAAA;QACd,CAAC;QAED,OAAO,MAAM,cAAI,CAAC,YAAY,CAAC,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,CAAA;IACnE,CAAC;AACH,CAAC","sourcesContent":["import { Domain } from '@things-factory/shell'\nimport { PrivilegeObject } from '../service/privilege/privilege.js'\nimport { User } from '../service/user/user.js'\n\nexport async function checkPermission(\n privilegeObject: PrivilegeObject,\n user: User,\n domain: Domain,\n unsafeIP?: boolean,\n prohibitedPrivileges?: { category: string; privilege: string }[]\n): Promise<boolean> {\n if (!privilegeObject) {\n return true\n }\n\n const { owner: domainOwnerGranted, super: superUserGranted, category, privilege } = privilegeObject\n\n if (unsafeIP) {\n if (privilege && category) {\n // unsafeIP 상황에서는 ownership granted는 적용되지 않는다.\n if ((prohibitedPrivileges || []).find(pp => pp.category == category && pp.privilege == privilege)) {\n return false\n }\n\n return await User.hasPrivilege(privilege, category, domain, user)\n }\n\n // privilege, category가 설정되지 않은 경우에는 ownership granted가 설정되었다면 허가하지 않는다.\n return !domainOwnerGranted && !superUserGranted\n } else {\n if (!privilege || !category) {\n // privilege, category가 설정되지 않은 경우에는 ownership granted만을 적용한다.\n return (\n (domainOwnerGranted && (await process.domainOwnerGranted(domain, user))) ||\n (superUserGranted && (await process.superUserGranted(domain, user)))\n )\n }\n\n if (\n (domainOwnerGranted && (await process.domainOwnerGranted(domain, user))) ||\n (superUserGranted && (await process.superUserGranted(domain, user)))\n ) {\n return true\n }\n\n if ((prohibitedPrivileges || []).find(pp => pp.category == category && pp.privilege == privilege)) {\n return false\n }\n\n return await User.hasPrivilege(privilege, category, domain, user)\n }\n}\n"]}
1
+ {"version":3,"file":"check-permission.js","sourceRoot":"","sources":["../../server/utils/check-permission.ts"],"names":[],"mappings":";;AAIA,0CAwDC;AA1DD,qDAA8C;AAEvC,KAAK,UAAU,eAAe,CACnC,eAAgC,EAChC,IAAU,EACV,MAAc,EACd,QAAkB,EAClB,oBAAgE;IAEhE,IAAI,CAAC,eAAe,EAAE,CAAC;QACrB,OAAO,IAAI,CAAA;IACb,CAAC;IAED,MAAM,EAAE,KAAK,EAAE,kBAAkB,EAAE,KAAK,EAAE,gBAAgB,EAAE,QAAQ,EAAE,SAAS,EAAE,GAAG,eAAe,CAAA;IAEnG,IAAI,QAAQ,EAAE,CAAC;QACb,IAAI,SAAS,IAAI,QAAQ,EAAE,CAAC;YAC1B,8CAA8C;YAC9C,IAAI,CAAC,oBAAoB,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,QAAQ,IAAI,QAAQ,IAAI,EAAE,CAAC,SAAS,IAAI,SAAS,CAAC,EAAE,CAAC;gBAClG,OAAO,KAAK,CAAA;YACd,CAAC;YAED,OAAO,MAAM,cAAI,CAAC,YAAY,CAAC,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,CAAA;QACnE,CAAC;QAED;;;;;;;;;WASG;QACH,OAAO,KAAK,CAAA;IACd,CAAC;SAAM,CAAC;QACN,IAAI,CAAC,SAAS,IAAI,CAAC,QAAQ,EAAE,CAAC;YAC5B,8DAA8D;YAC9D,OAAO,CACL,CAAC,kBAAkB,IAAI,CAAC,MAAM,OAAO,CAAC,kBAAkB,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC;gBACxE,CAAC,gBAAgB,IAAI,CAAC,MAAM,OAAO,CAAC,gBAAgB,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC,CACrE,CAAA;QACH,CAAC;QAED,IACE,CAAC,kBAAkB,IAAI,CAAC,MAAM,OAAO,CAAC,kBAAkB,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC;YACxE,CAAC,gBAAgB,IAAI,CAAC,MAAM,OAAO,CAAC,gBAAgB,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC,EACpE,CAAC;YACD,OAAO,IAAI,CAAA;QACb,CAAC;QAED,IAAI,CAAC,oBAAoB,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC,QAAQ,IAAI,QAAQ,IAAI,EAAE,CAAC,SAAS,IAAI,SAAS,CAAC,EAAE,CAAC;YAClG,OAAO,KAAK,CAAA;QACd,CAAC;QAED,OAAO,MAAM,cAAI,CAAC,YAAY,CAAC,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,CAAA;IACnE,CAAC;AACH,CAAC","sourcesContent":["import { Domain } from '@things-factory/shell'\nimport { PrivilegeObject } from '../service/privilege/privilege.js'\nimport { User } from '../service/user/user.js'\n\nexport async function checkPermission(\n privilegeObject: PrivilegeObject,\n user: User,\n domain: Domain,\n unsafeIP?: boolean,\n prohibitedPrivileges?: { category: string; privilege: string }[]\n): Promise<boolean> {\n if (!privilegeObject) {\n return true\n }\n\n const { owner: domainOwnerGranted, super: superUserGranted, category, privilege } = privilegeObject\n\n if (unsafeIP) {\n if (privilege && category) {\n // unsafeIP 상황에서는 ownership granted는 적용되지 않는다.\n if ((prohibitedPrivileges || []).find(pp => pp.category == category && pp.privilege == privilege)) {\n return false\n }\n\n return await User.hasPrivilege(privilege, category, domain, user)\n }\n\n /*\n * Ownership does not count from an unsafe address, so a declaration that rests on\n * ownership alone has nothing left to stand on here and the answer is no.\n *\n * This read `!domainOwnerGranted && !superUserGranted` until 2026-09-15, which answered\n * **yes** for an object that named nothing at all — the same `{}` a safe address\n * refused. An unsafe address was the more permissive of the two, which is backwards.\n * `privilege-directive.ts` now refuses to build a declaration that names nothing, but\n * this function is exported and called directly, so the rule is fixed here too.\n */\n return false\n } else {\n if (!privilege || !category) {\n // privilege, category가 설정되지 않은 경우에는 ownership granted만을 적용한다.\n return (\n (domainOwnerGranted && (await process.domainOwnerGranted(domain, user))) ||\n (superUserGranted && (await process.superUserGranted(domain, user)))\n )\n }\n\n if (\n (domainOwnerGranted && (await process.domainOwnerGranted(domain, user))) ||\n (superUserGranted && (await process.superUserGranted(domain, user)))\n ) {\n return true\n }\n\n if ((prohibitedPrivileges || []).find(pp => pp.category == category && pp.privilege == privilege)) {\n return false\n }\n\n return await User.hasPrivilege(privilege, category, domain, user)\n }\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/auth-base",
3
- "version": "10.1.6",
3
+ "version": "10.1.16",
4
4
  "main": "dist-server/index.js",
5
5
  "browser": "dist-client/index.js",
6
6
  "things-factory": true,
@@ -34,9 +34,9 @@
34
34
  "@reduxjs/toolkit": "^2.2.5",
35
35
  "@simplewebauthn/browser": "^13.0.0",
36
36
  "@simplewebauthn/server": "^13.0.0",
37
- "@things-factory/email-base": "^10.1.4",
37
+ "@things-factory/email-base": "^10.1.16",
38
38
  "@things-factory/env": "^10.1.3",
39
- "@things-factory/shell": "^10.1.4",
39
+ "@things-factory/shell": "^10.1.16",
40
40
  "@things-factory/utils": "^10.1.3",
41
41
  "@types/webappsec-credential-management": "^0.6.9",
42
42
  "jsonwebtoken": "^9.0.0",
@@ -48,5 +48,5 @@
48
48
  "passport-jwt": "^4.0.0",
49
49
  "passport-local": "^1.0.0"
50
50
  },
51
- "gitHead": "963aeb9ac470be5aea05bb5842a0a92afcab6036"
51
+ "gitHead": "aff86bc162b42c1d933711f7bc85a073f1ddcd29"
52
52
  }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * What `inherited: Only` binds when the domain has no parent.
3
+ *
4
+ * ── What was here until 2026-09-15 ───────────────────────────────────────────
5
+ * qb.andWhere(`${alias}.domain = :domain`, { domain: domain.parentId || 'Impossible' })
6
+ *
7
+ * "Match nothing" is the right intent — a root domain inherits from nobody — but it was
8
+ * spelled as a **value** bound to a uuid column. sqlite compares it as text and returns zero
9
+ * rows, which is the intended answer arrived at by accident, so it was green through three
10
+ * years of development. postgres parses it and refuses the whole query:
11
+ *
12
+ * invalid input syntax for type uuid: "Impossible" (observed 2026-09-15, path ["roles"])
13
+ *
14
+ * ── Why this test is written against the parameters, not the rows ────────────
15
+ * This harness is sqlite and cannot fail the way the server does; `tests/db.ts` says as much
16
+ * about column dialects. So it reads what TypeORM is about to **send** instead of what comes
17
+ * back. The defect lived in what we build, and what we build is the same on every driver —
18
+ * which makes this the way to reach the whole class our sqlite development cannot see.
19
+ */
20
+
21
+ import { closeAuthDatabase, Domain, getRepository, openAuthDatabase } from './db'
22
+ import { loadCompiled } from './compiled'
23
+
24
+ const { Role } = loadCompiled('service/role/role')
25
+
26
+ const shell = require('@things-factory/shell')
27
+
28
+ const ROOT = { id: '00000000-0000-4000-8000-000000000001', parentId: null }
29
+ const CHILD = { id: '00000000-0000-4000-8000-000000000002', parentId: ROOT.id }
30
+
31
+ beforeAll(async () => {
32
+ await openAuthDatabase()
33
+ })
34
+
35
+ afterAll(async () => {
36
+ await closeAuthDatabase()
37
+ })
38
+
39
+ function builderFor(domain: { id: string; parentId: string | null }, inherited: string) {
40
+ return shell.getQueryBuilderFromListParams({
41
+ repository: getRepository(Role),
42
+ params: { filters: [], inherited },
43
+ domain,
44
+ searchables: ['name', 'description']
45
+ })
46
+ }
47
+
48
+ function parametersFor(domain: { id: string; parentId: string | null }, inherited: string): any[] {
49
+ return builderFor(domain, inherited).getQueryAndParameters()[1]
50
+ }
51
+
52
+ function queryFor(domain: { id: string; parentId: string | null }, inherited: string): string {
53
+ return builderFor(domain, inherited).getQueryAndParameters()[0]
54
+ }
55
+
56
+ describe('inherited: Only', () => {
57
+ it('binds the parent id when there is one', () => {
58
+ expect(parametersFor(CHILD, 'Only')).toContain(ROOT.id)
59
+ })
60
+
61
+ it('binds nothing at all when there is no parent', () => {
62
+ /*
63
+ * The sentinel is gone. "A root domain inherits from nobody" is a condition, and it is now
64
+ * written as one — `1 = 0` — rather than as a value that has to survive a uuid parser.
65
+ */
66
+ expect(parametersFor(ROOT, 'Only')).not.toContain('Impossible')
67
+
68
+ const sql = queryFor(ROOT, 'Only')
69
+
70
+ expect(sql).toContain('1 = 0')
71
+ })
72
+
73
+ it('refuses a value that is not one of the three', () => {
74
+ /*
75
+ * An empty result would be read as "nothing matched". This is a tenant isolation filter;
76
+ * a malfunction here has to be loud, and the enum means no current caller can reach it.
77
+ */
78
+ expect(() => parametersFor(ROOT, 'Onyl')).toThrow(/unknown inherited value/)
79
+ })
80
+
81
+ it('binds nothing that is not a uuid for the other two scopes', () => {
82
+ /* None and Include are fine, which is why this went unnoticed: two of three scopes work. */
83
+ const uuidish = /^[0-9a-f-]{36}$/i
84
+
85
+ for (const scope of ['None', 'Include']) {
86
+ for (const parameter of parametersFor(ROOT, scope)) {
87
+ if (typeof parameter === 'string') {
88
+ expect(parameter).toMatch(uuidish)
89
+ }
90
+ }
91
+ }
92
+ })
93
+ })
@@ -128,9 +128,11 @@ describe('이름이 없는 선언 — 소유권만으로 판정한다', () => {
128
128
  expect(decision).toBeUndefined()
129
129
  })
130
130
 
131
- it('아무것도 선언하지 않은 빈 선언은 닫힌다', async () => {
132
- /* `{}` 는 `!privilegeObject` 가 아니므로 첫 관문을 통과하고, 여기서 falsy 로 떨어진다.
133
- 같은 `{}` 가 unsafeIP 에서는 **열린다** — 아래 unsafeIP 절이 그 비대칭을 붙잡는다. */
131
+ it('refuses an object that names nothing', async () => {
132
+ /* `{}` is not `!privilegeObject`, so it passes the first gate and falls out falsy here.
133
+ The unsafe-address block below asserts the same answer, which is the point: it used to
134
+ answer the opposite. `privilege-directive.ts` refuses to build this shape at all now,
135
+ but `checkPermission` is exported and reachable without it. */
134
136
  expect(await checkPermission({}, USER, DOMAIN)).toBeFalsy()
135
137
  })
136
138
  })
@@ -187,21 +189,23 @@ describe('안전하지 않은 IP (unsafeIP)', () => {
187
189
  })
188
190
 
189
191
  /*
190
- * ── 여기서부터가 뒤집힌 자리다 ─────────────────────────────────────────────
191
- * 규칙은 `return !domainOwnerGranted && !superUserGranted` (check-permission.ts:29).
192
- * 주석은 「unsafeIP 상황에서는 ownership granted 는 적용되지 않는다」고 적혀 있는데,
193
- * 「적용하지 않는다」의 결과가 **거절**이 아니라 **선언을 뒤집은 값**이다.
194
- * 아래 두 케이스는 그 결과를 그대로 고정한다 — 정책(§7)이 정해지면 시험을 먼저 바꾼다.
192
+ * ── An unsafe address is never the more permissive of the two ────────────────
193
+ * These two were marked 뒤집힘 and pinned the opposite answer until 2026-09-15. The rule
194
+ * read `return !domainOwnerGranted && !superUserGranted`: "ownership does not apply from an
195
+ * unsafe address" came out as the declaration inverted rather than as a refusal, so an
196
+ * object naming nothing opened from an unsafe address and closed from a safe one.
197
+ *
198
+ * The architect found the same asymmetry from the other side on 2026-09-15, looking at what
199
+ * a field would do if someone wrote `@privilege()` wanting "signed in is enough".
195
200
  */
196
- it('[뒤집힘] 소유권을 선언한 자리는 안전하지 않은 IP 에서 닫힌다', async () => {
201
+ it('refuses a declaration resting on ownership alone', async () => {
197
202
  withOwnership({ owner: true })
198
203
 
199
204
  expect(await checkPermission({ owner: true }, USER, DOMAIN, true)).toBe(false)
200
205
  })
201
206
 
202
- it('[뒤집힘] 아무것도 선언하지 않은 빈 선언은 안전하지 않은 IP 에서 오히려 열린다', async () => {
203
- /* 안전한 IP 에서는 같은 `{}` 가 닫힌다. 「없음」 기본값이 안전하지 않은 쪽으로 기운다. */
204
- expect(await checkPermission({}, USER, DOMAIN, true)).toBe(true)
207
+ it('refuses an object that names nothing, the same as a safe address does', async () => {
208
+ expect(await checkPermission({}, USER, DOMAIN, true)).toBe(false)
205
209
  expect(await checkPermission({}, USER, DOMAIN, false)).toBeFalsy()
206
210
  })
207
211
  })
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The axis vocabulary — who owns it, and what a value outside it does.
3
+ *
4
+ * The framework used to accept any string as an axis. `@privilege(privilege: "mutaiton")`
5
+ * registered a pair in `process['PRIVILEGES']` that nobody would ever be granted: a gate shut
6
+ * forever, with nothing anywhere to read. Closing the axis to `query | mutation` in the
7
+ * framework would have fixed that and broken dssp's 40 declarations, so the owner declares
8
+ * and the framework asks.
9
+ */
10
+
11
+ import { loadCompiled } from './compiled'
12
+
13
+ const { declareAxes, declaredAxes, isDeclaredAxis, readOnlyAxes, readOnlyAxesAreKnown, DEFAULT_AXES } = loadCompiled(
14
+ 'service/privilege/privilege-axis'
15
+ )
16
+
17
+ beforeEach(() => {
18
+ delete (process as any).PRIVILEGE_AXES
19
+ })
20
+
21
+ afterEach(() => {
22
+ delete (process as any).PRIVILEGE_AXES
23
+ })
24
+
25
+ describe('an installation that declares nothing', () => {
26
+ it('gets query and mutation', () => {
27
+ expect([...declaredAxes()].sort()).toEqual(['mutation', 'query'])
28
+ expect(DEFAULT_AXES).toEqual(['query', 'mutation'])
29
+ })
30
+
31
+ it('still refuses a value outside them', () => {
32
+ /*
33
+ * The defaults are a vocabulary, not an off switch. An installation that forgot to declare
34
+ * is the common case, and it is the one that most needs the typo caught.
35
+ */
36
+ expect(isDeclaredAxis('query')).toBe(true)
37
+ expect(isDeclaredAxis('mutaiton')).toBe(false)
38
+ })
39
+ })
40
+
41
+ describe('an installation that declares its own words', () => {
42
+ it('accepts them', () => {
43
+ declareAxes(['read', 'input', 'finalize'])
44
+
45
+ expect(isDeclaredAxis('read')).toBe(true)
46
+ expect(isDeclaredAxis('finalize')).toBe(true)
47
+ })
48
+
49
+ it('replaces the defaults rather than adding to them', () => {
50
+ /*
51
+ * A house that says "my axes are read and input" has said what its axes are. Keeping
52
+ * query and mutation alongside would let a declaration use a word this installation
53
+ * does not actually mean, which is the thing being prevented.
54
+ */
55
+ declareAxes(['read', 'input'])
56
+
57
+ expect(isDeclaredAxis('query')).toBe(false)
58
+ expect([...declaredAxes()].sort()).toEqual(['input', 'read'])
59
+ })
60
+ })
61
+
62
+ describe('more than one module declaring', () => {
63
+ it('takes the union, because one installation mixes vocabularies', () => {
64
+ /*
65
+ * This is dssp: `project` is declared with query/mutation while `kpi` uses eight words of
66
+ * its own, in the same installation. Each module says what it uses.
67
+ */
68
+ declareAxes(['query', 'mutation'])
69
+ declareAxes(['read', 'assessment'])
70
+
71
+ expect([...declaredAxes()].sort()).toEqual(['assessment', 'mutation', 'query', 'read'])
72
+ })
73
+
74
+ it('does not treat the same axis twice as a conflict', () => {
75
+ /* Two modules reaching for `read` independently is the case this is built for. */
76
+ declareAxes(['read'])
77
+ declareAxes(['read'])
78
+
79
+ expect([...declaredAxes()]).toEqual(['read'])
80
+ })
81
+ })
82
+
83
+ describe('what declareAxes refuses', () => {
84
+ it('refuses an empty list, which would refuse every declaration', () => {
85
+ expect(() => declareAxes([])).toThrow(/at least one axis/)
86
+ expect(() => declareAxes(undefined as any)).toThrow(/at least one axis/)
87
+ })
88
+
89
+ it('refuses a value that is not a word', () => {
90
+ expect(() => declareAxes([''])).toThrow(/non-empty strings/)
91
+ expect(() => declareAxes([' '])).toThrow(/non-empty strings/)
92
+ expect(() => declareAxes([null as any])).toThrow(/non-empty strings/)
93
+ })
94
+
95
+ it('changes nothing when it refuses', () => {
96
+ declareAxes(['read'], { readOnly: ['read'] })
97
+
98
+ expect(() => declareAxes(['input', ''])).toThrow()
99
+
100
+ /*
101
+ * Every value is checked before any is stored, so a refused call leaves the vocabulary as
102
+ * it was. An earlier draft added axes as it walked the list and stopped at the bad one,
103
+ * which left `input` declared by a call that threw.
104
+ */
105
+ expect(isDeclaredAxis('input')).toBe(false)
106
+ expect(isDeclaredAxis('read')).toBe(true)
107
+ })
108
+
109
+ it('refuses a read-only axis that is not in the same call', () => {
110
+ /*
111
+ * A typo here is the quietest kind: `readOnly: ['querry']` would mark nothing, VIEWER
112
+ * would lose every category of this module, and the screen would count the shortfall
113
+ * correctly and present it as fact.
114
+ */
115
+ expect(() => declareAxes(['query', 'mutation'], { readOnly: ['querry'] })).toThrow(/not in the same call/)
116
+ expect(() => declareAxes(['read'], { readOnly: ['query'] })).toThrow(/Declared here: read/)
117
+ })
118
+ })
119
+
120
+ describe('which axes are read-only', () => {
121
+ it('defaults to query when nobody declared anything', () => {
122
+ /* The vocabulary and the answer to "which half is safe" travel together. */
123
+ expect([...readOnlyAxes()]).toEqual(['query'])
124
+ expect(readOnlyAxesAreKnown()).toBe(true)
125
+ })
126
+
127
+ it('is unknown when a house declared its own words and did not say', () => {
128
+ declareAxes(['read', 'input', 'finalize'])
129
+
130
+ expect(readOnlyAxes().size).toBe(0)
131
+ expect(readOnlyAxesAreKnown()).toBe(false)
132
+ })
133
+
134
+ it('takes the union across modules, like the axes themselves', () => {
135
+ declareAxes(['query', 'mutation'], { readOnly: ['query'] })
136
+ declareAxes(['read', 'finalize'], { readOnly: ['read'] })
137
+
138
+ expect([...readOnlyAxes()].sort()).toEqual(['query', 'read'])
139
+ })
140
+
141
+ it('does not mean the operation is a @Query', () => {
142
+ /*
143
+ * `exportLabelStudioAnnotations` is declared `@Mutation` and gated on `label-studio:query`
144
+ * [읽었음 task-management.ts:200-204]. It calls an external API's export and returns what
145
+ * comes back; nothing in our database moves. The axis is the correct one, and a role
146
+ * holding only that axis still changes nothing.
147
+ *
148
+ * This test carries no assertion about that file — it cannot, from here. It exists so the
149
+ * next person reading `readOnly` finds the distinction spelled out beside the code rather
150
+ * than only in a comment they might skip.
151
+ */
152
+ declareAxes(['query', 'mutation'], { readOnly: ['query'] })
153
+
154
+ expect(readOnlyAxes().has('query')).toBe(true)
155
+ expect(readOnlyAxes().has('mutation')).toBe(false)
156
+ })
157
+ })
@@ -13,7 +13,6 @@
13
13
 
14
14
  import { graphql } from 'graphql'
15
15
  import { makeExecutableSchema } from '@graphql-tools/schema'
16
- import { logger } from '@things-factory/env'
17
16
 
18
17
  import { loadCompiled } from './compiled'
19
18
 
@@ -34,7 +33,6 @@ const typeDefs = `
34
33
  guardedBoard: String @privilege(category: "board", privilege: "mutation")
35
34
  ownershipOnly: String @privilege(domainOwnerGranted: true)
36
35
  undeclared: String
37
- moved: String @privilege(category: "ai-assistant", privilege: "mutation", legacy: "board-ai")
38
36
  }
39
37
  `
40
38
 
@@ -45,8 +43,7 @@ function buildSchema() {
45
43
  Query: {
46
44
  guardedBoard: () => 'board',
47
45
  ownershipOnly: () => 'ownership',
48
- undeclared: () => 'undeclared',
49
- moved: () => 'moved'
46
+ undeclared: () => 'undeclared'
50
47
  }
51
48
  }
52
49
  })
@@ -54,13 +51,6 @@ function buildSchema() {
54
51
  return privilegeDirectiveResolver(schema)
55
52
  }
56
53
 
57
- /** 이 category 들만 가진 사람. `hasPrivilege(privilege, category, ...)` 순서를 그대로 받는다. */
58
- function granted(...categories: string[]) {
59
- User.hasPrivilege = jest.fn().mockImplementation(async (_privilege: string, category: string) =>
60
- categories.includes(category)
61
- )
62
- }
63
-
64
54
  function run(schema: any, field: string, state: any = {}) {
65
55
  return graphql({
66
56
  schema,
@@ -173,98 +163,97 @@ describe('안전하지 않은 IP 가 이음매를 지나 게이트까지 간다'
173
163
  })
174
164
  })
175
165
 
176
- /**
177
- * 권한 이름을 옮기는 중.
178
- *
179
- * 옛 이름을 받아 주지 않으면 옮긴 그날 그 기능을 쓰던 사람들이 전부 막힌다. 그런데 옛 부여를
180
- * 역할 행에 베껴 넣으면 조용히 권한이 번지고 되돌릴 근거가 없어진다. 그래서 **행은 그대로 두고
181
- * 문에서 한동안 받아 준다.**
182
- */
183
- describe('옮기는 중인 권한', () => {
184
- it('새 이름을 가졌으면 열린다', async () => {
185
- granted('ai-assistant')
186
-
187
- const result = await run(buildSchema(), 'moved')
166
+ describe('스키마를 지으면서 남기는 것', () => {
167
+ it('선언한 (category, privilege) 를 전역 레지스트리에 등록한다', () => {
168
+ /* 부팅 때 없는 권한 행을 만드는 근거다 — 마이그레이션도 수동 생성도 필요 없다. */
169
+ buildSchema()
188
170
 
189
- expect(result.errors).toBeUndefined()
190
- expect(result.data).toEqual({ moved: 'moved' })
171
+ expect((process as any).PRIVILEGES['board mutation']).toEqual(['board', 'mutation'])
191
172
  })
192
173
 
193
- it('새 이름이 없고 옛 이름만 있어도 열린다 — 옮긴 날 아무도 막히지 않는다', async () => {
194
- granted('board-ai')
195
-
196
- const result = await run(buildSchema(), 'moved')
174
+ it('필드 설명에 필요한 권한을 적어 둔다', async () => {
175
+ const schema = buildSchema()
176
+ const description = (schema.getQueryType() as any).getFields().guardedBoard.description
197
177
 
198
- expect(result.errors).toBeUndefined()
199
- expect(result.data).toEqual({ moved: 'moved' })
178
+ expect(description).toContain('board:mutation')
200
179
  })
180
+ })
201
181
 
202
- it('둘 다 없으면 거절하고, 필요한 것은 새 이름으로 말한다', async () => {
203
- granted()
182
+ /*
183
+ * ── A declaration that names nothing stops the boot ───────────────────────────
184
+ * Schema assembly is the only moment this can be caught. After it, an empty declaration is
185
+ * indistinguishable from a real one at the call site: the directive is present, the wrapper
186
+ * runs, and the answer comes back from `checkPermission` with nothing to look up.
187
+ *
188
+ * `@privilege()` was the worst of these — it answered by the caller's address, refusing from a
189
+ * safe one and passing everyone from an unsafe one, so whoever tried it learned the opposite
190
+ * lesson depending on where they sat (architect, 2026-09-15).
191
+ *
192
+ * [확인함] 2026-09-15 · six repositories · zero declarations of either shape, so refusing them
193
+ * breaks nothing that exists. The throw is for the one someone writes next.
194
+ */
195
+ describe('a declaration that names nothing', () => {
196
+ function buildField(declaration: string) {
197
+ const schema = makeExecutableSchema({
198
+ typeDefs: [privilegeDirectiveTypeDefs, `type Query { field: String ${declaration} }`],
199
+ resolvers: { Query: { field: () => 'value' } }
200
+ })
204
201
 
205
- const result = await run(buildSchema(), 'moved')
202
+ return () => privilegeDirectiveResolver(schema)
203
+ }
206
204
 
207
- expect(result.data).toEqual({ moved: null })
208
- expect(result.errors).toHaveLength(1)
209
- /* 옛 이름을 말하면 관리자가 곧 사라질 권한을 부여한다. */
210
- expect((result.errors![0].extensions as any).requires).toEqual({
211
- category: 'ai-assistant',
212
- privilege: 'mutation'
213
- })
205
+ it('refuses an empty declaration, and names the field', () => {
206
+ expect(buildField('@privilege')).toThrow(/Query\.field declares nothing/)
214
207
  })
215
208
 
216
- it('금지 목록이 새 쌍을 막고 있으면 옛 이름으로도 열리지 않는다', async () => {
217
- /*
218
- * 금지 목록은 옛 이름을 모른다. 별칭이 금지를 되돌리면 「이 IP 에서는 이것을 못 한다」는
219
- * 설정이 조용히 무력해진다.
220
- */
221
- granted('board-ai')
222
-
223
- const result = await run(buildSchema(), 'moved', {
224
- unsafeIP: true,
225
- prohibitedPrivileges: [{ category: 'ai-assistant', privilege: 'mutation' }]
226
- })
209
+ it('tells the writer what the two real shapes are', () => {
210
+ expect(buildField('@privilege')).toThrow(/domainOwnerGranted/)
211
+ expect(buildField('@privilege')).toThrow(/carries no directive at all/)
212
+ })
227
213
 
228
- expect(result.errors).toHaveLength(1)
214
+ it('refuses a category with no privilege', () => {
215
+ expect(buildField('@privilege(category: "board")')).toThrow(/a category with no privilege/)
229
216
  })
230
217
 
231
- it('옛 이름을 부른 것을 로그에 남긴다 — 누가 기대고 있는지가 부여의 근거다', async () => {
232
- granted('board-ai')
218
+ it('refuses a privilege with no category', () => {
219
+ expect(buildField('@privilege(privilege: "mutation")')).toThrow(/a privilege with no category/)
220
+ })
233
221
 
234
- const warn = jest.spyOn(logger, 'warn')
222
+ /*
223
+ * ── An axis this installation does not declare ──────────────────────────────
224
+ * A typo used to be the quietest defect in the file. `privilege: "mutaiton"` registered a
225
+ * pair in `process['PRIVILEGES']` that no role would ever hold — the field was shut to
226
+ * everyone, permanently, and nothing said so. The framework does not own the word list
227
+ * (dssp declares eight of its own), so the check asks the installation.
228
+ */
229
+ it('refuses an axis outside the declared vocabulary', () => {
230
+ expect(buildField('@privilege(category: "board", privilege: "mutaiton")')).toThrow(
231
+ /uses axis "mutaiton", which this installation does not declare/
232
+ )
233
+ })
235
234
 
236
- await run(buildSchema(), 'moved')
235
+ it('tells the reader what is declared and where to declare more', () => {
236
+ const build = buildField('@privilege(category: "board", privilege: "approve")')
237
237
 
238
- expect(warn).toHaveBeenCalled()
239
- expect(warn.mock.calls[0][0]).toContain('board-ai:mutation')
240
- warn.mockRestore()
238
+ expect(build).toThrow(/Declared axes: mutation, query/)
239
+ /* The hook a module would reach for runs after the schema is built, so it must say so. */
240
+ expect(build).toThrow(/top level of the module's server\/index\.ts/)
241
+ expect(build).toThrow(/too late/)
241
242
  })
242
- })
243
243
 
244
- describe('스키마를 지으면서 남기는 것', () => {
245
- it('선언한 (category, privilege) 를 전역 레지스트리에 등록한다', () => {
246
- /* 부팅 때 없는 권한 행을 만드는 근거다 — 마이그레이션도 수동 생성도 필요 없다. */
247
- buildSchema()
244
+ it('accepts an axis once the installation declares it', () => {
245
+ const { declareAxes } = loadCompiled('service/privilege/privilege-axis')
248
246
 
249
- expect((process as any).PRIVILEGES['board mutation']).toEqual(['board', 'mutation'])
250
- })
247
+ expect(buildField('@privilege(category: "kpi", privilege: "read")')).toThrow()
251
248
 
252
- it('옛 이름은 등록하지 않는다 — 그래서 옛 권한 행이 유령으로 보인다', () => {
253
- /*
254
- * `isDeprecatedPrivilege` 는 「레지스트리에 없으면 유령」으로 판정한다
255
- * (`privilege-deprecation.ts`). 옛 쌍을 등록하면 관리자 목록에서 옛 행이 정상으로 보여
256
- * **별칭을 지울 시점을 아무도 못 본다.** 이 시험이 그 신호를 지킨다.
257
- */
258
- buildSchema()
249
+ declareAxes(['query', 'mutation', 'read'])
259
250
 
260
- expect((process as any).PRIVILEGES['ai-assistant mutation']).toEqual(['ai-assistant', 'mutation'])
261
- expect((process as any).PRIVILEGES['board-ai mutation']).toBeUndefined()
251
+ expect(buildField('@privilege(category: "kpi", privilege: "read")')).not.toThrow()
262
252
  })
263
253
 
264
- it('필드 설명에 필요한 권한을 적어 둔다', async () => {
265
- const schema = buildSchema()
266
- const description = (schema.getQueryType() as any).getFields().guardedBoard.description
267
-
268
- expect(description).toContain('board:mutation')
254
+ it('still builds the two shapes that do name something', () => {
255
+ expect(buildField('@privilege(category: "board", privilege: "mutation")')).not.toThrow()
256
+ expect(buildField('@privilege(domainOwnerGranted: true)')).not.toThrow()
257
+ expect(buildField('@privilege(superUserGranted: true)')).not.toThrow()
269
258
  })
270
259
  })