@things-factory/auth-base 10.1.7 → 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.
@@ -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.7",
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.7",
37
+ "@things-factory/email-base": "^10.1.16",
38
38
  "@things-factory/env": "^10.1.3",
39
- "@things-factory/shell": "^10.1.7",
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": "9a036f83f5751f9e4af9180e9a0018f716a4ba27"
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
+ })
@@ -178,3 +178,82 @@ describe('스키마를 지으면서 남기는 것', () => {
178
178
  expect(description).toContain('board:mutation')
179
179
  })
180
180
  })
181
+
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
+ })
201
+
202
+ return () => privilegeDirectiveResolver(schema)
203
+ }
204
+
205
+ it('refuses an empty declaration, and names the field', () => {
206
+ expect(buildField('@privilege')).toThrow(/Query\.field declares nothing/)
207
+ })
208
+
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
+ })
213
+
214
+ it('refuses a category with no privilege', () => {
215
+ expect(buildField('@privilege(category: "board")')).toThrow(/a category with no privilege/)
216
+ })
217
+
218
+ it('refuses a privilege with no category', () => {
219
+ expect(buildField('@privilege(privilege: "mutation")')).toThrow(/a privilege with no category/)
220
+ })
221
+
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
+ })
234
+
235
+ it('tells the reader what is declared and where to declare more', () => {
236
+ const build = buildField('@privilege(category: "board", privilege: "approve")')
237
+
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/)
242
+ })
243
+
244
+ it('accepts an axis once the installation declares it', () => {
245
+ const { declareAxes } = loadCompiled('service/privilege/privilege-axis')
246
+
247
+ expect(buildField('@privilege(category: "kpi", privilege: "read")')).toThrow()
248
+
249
+ declareAxes(['query', 'mutation', 'read'])
250
+
251
+ expect(buildField('@privilege(category: "kpi", privilege: "read")')).not.toThrow()
252
+ })
253
+
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()
258
+ })
259
+ })
@@ -17,7 +17,11 @@ const {
17
17
  unknownTemplateGrants
18
18
  } = loadCompiled('service/role-template/role-template')
19
19
 
20
+ const { declareAxes } = loadCompiled('service/privilege/privilege-axis')
21
+
20
22
  beforeEach(() => {
23
+ /* Nobody declared, so the defaults apply — query/mutation with query read-only. */
24
+ delete (process as any).PRIVILEGE_AXES
21
25
  ;(process as any)['ROLE_TEMPLATES'] = {}
22
26
  ;(process as any).PRIVILEGES = {
23
27
  'board query': ['board', 'query'],
@@ -118,6 +122,38 @@ describe('the two templates the framework computes', () => {
118
122
  expect(viewer.grants.find(grant => grant.category === 'board')!.axes).toEqual(['query'])
119
123
  })
120
124
 
125
+ it('builds the viewer from the declared read-only axes, not from the word "query"', () => {
126
+ /*
127
+ * A house that spells its read axis `read` gets a viewer over `read`. Matching the literal
128
+ * `query` was right here and wrong there — dssp's `kpi` has eight axes and none of them is
129
+ * `query`, so its main module would have gone missing from VIEWER without a word anywhere.
130
+ */
131
+ declareAxes(['read', 'input'], { readOnly: ['read'] })
132
+ ;(process as any).PRIVILEGES = {
133
+ 'kpi read': ['kpi', 'read'],
134
+ 'kpi input': ['kpi', 'input']
135
+ }
136
+
137
+ const viewer = computedRoleTemplates().find(one => one.id === 'framework.viewer')!
138
+
139
+ expect(viewer.grants).toEqual([{ category: 'kpi', axes: ['read'] }])
140
+ })
141
+
142
+ it('does not offer a viewer at all when nobody said which axes are read-only', () => {
143
+ /*
144
+ * ⚠ The point of this one. Offering an empty VIEWER would let an administrator pick it,
145
+ * get a role, and open nothing — and the role list would count that zero correctly and
146
+ * present it as fact. "We do not know" is not "there are none", so the template is
147
+ * withheld and `reportDeclaredAxes` says so at boot.
148
+ */
149
+ declareAxes(['read', 'input'])
150
+
151
+ const ids = computedRoleTemplates().map(one => one.id)
152
+
153
+ expect(ids).toEqual(['framework.administrator'])
154
+ expect(ids).not.toContain('framework.viewer')
155
+ })
156
+
121
157
  it('offers the computed two alongside whatever applications declared', () => {
122
158
  registerRoleTemplate({ id: 'plant.z', name: 'Z', grants: [] })
123
159