@things-factory/auth-base 10.1.37 → 10.1.39

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 (37) hide show
  1. package/dist-client/auth.d.ts +8 -0
  2. package/dist-client/auth.js +26 -0
  3. package/dist-client/auth.js.map +1 -1
  4. package/dist-client/domain-required.d.ts +54 -0
  5. package/dist-client/domain-required.js +91 -0
  6. package/dist-client/domain-required.js.map +1 -0
  7. package/dist-client/tsconfig.tsbuildinfo +1 -1
  8. package/dist-server/controllers/invitation.d.ts +26 -0
  9. package/dist-server/controllers/invitation.js +12 -1
  10. package/dist-server/controllers/invitation.js.map +1 -1
  11. package/dist-server/service/app-binding/app-binding-query.d.ts +17 -0
  12. package/dist-server/service/app-binding/app-binding-query.js +32 -2
  13. package/dist-server/service/app-binding/app-binding-query.js.map +1 -1
  14. package/dist-server/service/index.d.ts +7 -2
  15. package/dist-server/service/index.js +3 -0
  16. package/dist-server/service/index.js.map +1 -1
  17. package/dist-server/service/open-fields.d.ts +9 -0
  18. package/dist-server/service/open-fields.js +24 -0
  19. package/dist-server/service/open-fields.js.map +1 -0
  20. package/dist-server/service/privilege/open-field-rule.d.ts +93 -0
  21. package/dist-server/service/privilege/open-field-rule.js +81 -0
  22. package/dist-server/service/privilege/open-field-rule.js.map +1 -0
  23. package/dist-server/service/privilege/privilege-directive.d.ts +9 -1
  24. package/dist-server/service/privilege/privilege-directive.js +177 -95
  25. package/dist-server/service/privilege/privilege-directive.js.map +1 -1
  26. package/dist-server/tsconfig.tsbuildinfo +1 -1
  27. package/package.json +4 -4
  28. package/tests/app-binding-db.test.ts +48 -0
  29. package/tests/domain-required.test.ts +127 -0
  30. package/tests/invitation-route.test.ts +97 -0
  31. package/tests/open-doors.test.ts +13 -21
  32. package/tests/open-fields-enforcement.test.ts +215 -0
  33. package/translations/en.json +0 -1
  34. package/translations/ja.json +0 -1
  35. package/translations/ko.json +0 -1
  36. package/translations/ms.json +0 -1
  37. package/translations/zh.json +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/auth-base",
3
- "version": "10.1.37",
3
+ "version": "10.1.39",
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.37",
37
+ "@things-factory/email-base": "^10.1.39",
38
38
  "@things-factory/env": "^10.1.20",
39
- "@things-factory/shell": "^10.1.37",
39
+ "@things-factory/shell": "^10.1.39",
40
40
  "@things-factory/utils": "^10.1.20",
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": "d8a69e5c8803a4b9d3289df0cf38e161a207db61"
51
+ "gitHead": "fe5257ad8995fe7e79275e287f36e95b5fd32d1c"
52
52
  }
@@ -160,3 +160,51 @@ describe('the list', () => {
160
160
  expect(items).toHaveLength(1)
161
161
  })
162
162
  })
163
+
164
+ describe('the free-text term', () => {
165
+ /*
166
+ * The screen now sends the term itself (`ListParam.search`) and lets the server say where it
167
+ * looks. This door builds with the older `buildQuery`, which only understood search *filters* -
168
+ * so a term that arrived here was dropped, and the whole list came back as if it were the answer.
169
+ * A person searching for a binding's name read that as "not here".
170
+ */
171
+ it('narrows the list to the bindings that match', async () => {
172
+ await makeUser(ours, 'erp-bridge', 'application')
173
+ await makeUser(ours, 'label-printer', 'application')
174
+ await makeUser(ours, 'erp-archive', 'application')
175
+
176
+ const { items, total } = await query.appBindings({ search: 'erp' } as any, context(ours))
177
+
178
+ expect(items.map((one: any) => one.username).sort()).toEqual(['erp-archive', 'erp-bridge'])
179
+ expect(total).toBe(2)
180
+ })
181
+
182
+ it('looks in the email too', async () => {
183
+ await makeUser(ours, 'scanner-1', 'application')
184
+ await makeUser(ours, 'scanner-2', 'application')
185
+
186
+ const { items } = await query.appBindings({ search: 'scanner-2@example' } as any, context(ours))
187
+
188
+ expect(items.map((one: any) => one.username)).toEqual(['scanner-2'])
189
+ })
190
+
191
+ it('still answers only for this domain when a term is given', async () => {
192
+ /* A term must not widen the tenant: the OR of the search sits inside the domain condition. */
193
+ await makeUser(ours, 'erp-ours', 'application')
194
+ await makeUser(theirs, 'erp-theirs', 'application')
195
+
196
+ const { items } = await query.appBindings({ search: 'erp' } as any, context(ours))
197
+
198
+ expect(items.map((one: any) => one.username)).toEqual(['erp-ours'])
199
+ })
200
+
201
+ it('an empty term is no term', async () => {
202
+ await makeUser(ours, 'app-one', 'application')
203
+ await makeUser(ours, 'app-two', 'application')
204
+
205
+ const { total } = await query.appBindings({ search: ' ' } as any, context(ours))
206
+
207
+ expect(total).toBe(2)
208
+ })
209
+ })
210
+
@@ -0,0 +1,127 @@
1
+ import { decideDomainRedirect, DOMAIN_REDIRECT_WINDOW_MS, domainRequiredTarget } from '../client/domain-required'
2
+
3
+ /**
4
+ * Where a screen that came up before the shell settled on a domain is sent.
5
+ *
6
+ * Every query on such a screen answers 403, and "try again" answers 403 again, forever. The shell
7
+ * sends the reader to check-in - the same place the server already sends an HTML request - and
8
+ * never from check-in or sign-in themselves, where it would go round in a circle.
9
+ */
10
+
11
+ describe('domainRequiredTarget', () => {
12
+ test('a screen without a domain segment goes to check-in, carrying where it was', () => {
13
+ const target = new URL(domainRequiredTarget('http://localhost:4000/activity-catalog')!)
14
+
15
+ expect(target.pathname).toBe('/auth/checkin')
16
+ expect(target.searchParams.get('redirect_to')).toBe('http://localhost:4000/activity-catalog')
17
+ })
18
+
19
+ test('a screen on a domain the reader may not enter also goes to check-in - the server names why', () => {
20
+ const target = new URL(domainRequiredTarget('http://localhost:4000/domain/other/activity-catalog')!)
21
+
22
+ expect(target.pathname).toBe('/auth/checkin')
23
+ expect(target.searchParams.get('redirect_to')).toBe('http://localhost:4000/domain/other/activity-catalog')
24
+ })
25
+
26
+ test('the query string and hash of the screen survive the round trip', () => {
27
+ const from = 'http://localhost:4000/activity-detail?name=MES%20Disposition%20Approval#line'
28
+ const target = new URL(domainRequiredTarget(from)!)
29
+
30
+ expect(target.searchParams.get('redirect_to')).toBe(from)
31
+ })
32
+
33
+ test('it stays on the same origin', () => {
34
+ expect(new URL(domainRequiredTarget('https://plant.example.com/x')!).origin).toBe('https://plant.example.com')
35
+ })
36
+
37
+ test('check-in itself is never sent to check-in', () => {
38
+ expect(domainRequiredTarget('http://localhost:4000/auth/checkin?redirect_to=%2F')).toBeNull()
39
+ })
40
+
41
+ test('sign-in and the other auth screens are left alone', () => {
42
+ expect(domainRequiredTarget('http://localhost:4000/auth/signin')).toBeNull()
43
+ expect(domainRequiredTarget('http://localhost:4000/auth')).toBeNull()
44
+ })
45
+
46
+ test('a path that only starts with the letters auth is not an auth screen', () => {
47
+ expect(domainRequiredTarget('http://localhost:4000/authority-list')).not.toBeNull()
48
+ })
49
+
50
+ test('something that is not an address is not guessed at', () => {
51
+ expect(domainRequiredTarget('')).toBeNull()
52
+ expect(domainRequiredTarget('not a url')).toBeNull()
53
+ })
54
+
55
+ /*
56
+ * A 403 is not only "no domain". The shell also answers 403 to an address on the site's IP block list
57
+ * (shell/server/middlewares/domain-middleware.ts). That reader has a domain; check-in sends them
58
+ * straight back and the screen gets 403 again. Without this guard the tab goes round forever.
59
+ */
60
+ describe('after one round trip it stops', () => {
61
+ const now = 1_000_000_000
62
+ const here = 'http://localhost:4000/domain/miratek/activity-catalog'
63
+
64
+ test('sent a moment ago: not sent again, whatever the reason for the 403', () => {
65
+ expect(domainRequiredTarget(here, now - 2_000, now)).toBeNull()
66
+ })
67
+
68
+ test('it is the time that counts, not the address - the server changed the address on the way back', () => {
69
+ expect(domainRequiredTarget('http://localhost:4000/activity-catalog', now - 2_000, now)).toBeNull()
70
+ expect(domainRequiredTarget(here, now - 2_000, now)).toBeNull()
71
+ })
72
+
73
+ test('once the window has passed, a new 403 is a new case and is sent', () => {
74
+ expect(domainRequiredTarget(here, now - DOMAIN_REDIRECT_WINDOW_MS, now)).not.toBeNull()
75
+ })
76
+
77
+ test('never sent before: sent', () => {
78
+ expect(domainRequiredTarget(here, null, now)).not.toBeNull()
79
+ expect(domainRequiredTarget(here, undefined, now)).not.toBeNull()
80
+ })
81
+
82
+ test('a stamp from the future (a clock that moved back) does not block for ever', () => {
83
+ expect(domainRequiredTarget(here, now + 60_000, now)).not.toBeNull()
84
+ })
85
+ })
86
+
87
+ /*
88
+ * The stamp is the only thing that knows "we just came back". A browser that blocks storage would lose it,
89
+ * and a reader on the IP block list would go round again. So storage that does not work means: do not send.
90
+ */
91
+ describe('decideDomainRedirect - the stamp must work or nothing is sent', () => {
92
+ const now = 1_000_000_000
93
+ const here = 'http://localhost:4000/activity-catalog'
94
+ const store = (saved: string | null = null) => {
95
+ const box = { value: saved, written: [] as string[] }
96
+ return { box, stamp: { read: () => box.value, write: (v: string) => { box.written.push(v); box.value = v } } }
97
+ }
98
+
99
+ test('first time: sent, and the stamp is written before leaving', () => {
100
+ const { box, stamp } = store()
101
+ expect(decideDomainRedirect(here, stamp, now)).not.toBeNull()
102
+ expect(box.written).toEqual([String(now)])
103
+ })
104
+
105
+ test('just came back: not sent, and the stamp is left as it was', () => {
106
+ const { box, stamp } = store(String(now - 2_000))
107
+ expect(decideDomainRedirect(here, stamp, now)).toBeNull()
108
+ expect(box.written).toEqual([])
109
+ })
110
+
111
+ test('storage that cannot be read: not sent', () => {
112
+ const stamp = { read: () => { throw new Error('blocked') }, write: () => {} }
113
+ expect(decideDomainRedirect(here, stamp, now)).toBeNull()
114
+ })
115
+
116
+ test('storage that cannot be written: not sent - a trip we cannot remember could loop', () => {
117
+ const stamp = { read: () => null, write: () => { throw new Error('quota') } }
118
+ expect(decideDomainRedirect(here, stamp, now)).toBeNull()
119
+ })
120
+
121
+ test('a stamp that is not a number is treated as none, and the trip goes ahead', () => {
122
+ const { stamp } = store('garbage')
123
+ expect(decideDomainRedirect(here, stamp, now)).not.toBeNull()
124
+ })
125
+ })
126
+ })
127
+
@@ -0,0 +1,97 @@
1
+ import { readFileSync, readdirSync } from 'fs'
2
+ import { join } from 'path'
3
+
4
+ import { loadCompiled } from './compiled'
5
+
6
+ const { authPublicProcessRouter } = loadCompiled('router/auth-public-process-router')
7
+ const { ACCEPT_INVITATION_PATH } = loadCompiled('controllers/invitation')
8
+
9
+ /**
10
+ * The link in each mail lands on a route that exists.
11
+ *
12
+ * ── Why this file exists ────────────────────────────────────────────────────
13
+ * The invitation mail pointed at `/auth/accept/<token>` — a path with no route anywhere — so the
14
+ * invitee fell through to the private history fallback and the domain gate turned them away for
15
+ * not being a member yet. That was found and fixed on 2026-09-20, and the fix made the same
16
+ * mistake again: the new route was declared as `/accept-invitation` on a router whose prefix is
17
+ * `/auth`, and the mail and the page both used `/accept-invitation`. It shipped that way.
18
+ *
19
+ * Every test of the invitation called the controller directly, so none of them ever went through
20
+ * a route. This one reads the router's own table — the paths koa-router resolved, prefix included
21
+ * — and holds each mail's link against it.
22
+ */
23
+
24
+ type Layer = { path: string; methods: string[] }
25
+
26
+ const layers: Layer[] = authPublicProcessRouter.stack.map((layer: any) => ({
27
+ path: layer.path,
28
+ methods: layer.methods
29
+ }))
30
+
31
+ function answers(method: string, path: string): boolean {
32
+ return layers.some(layer => layer.methods.includes(method) && matches(layer.path, path))
33
+ }
34
+
35
+ /** A declared path against a concrete one: `:param` matches one segment. */
36
+ function matches(declared: string, actual: string): boolean {
37
+ const a = declared.split('/')
38
+ const b = actual.split('/')
39
+
40
+ return a.length === b.length && a.every((part, i) => part.startsWith(':') || part === b[i])
41
+ }
42
+
43
+ describe('the invitation link', () => {
44
+ it('the page answers where the mail points', () => {
45
+ /* ⚠ The case that shipped broken. */
46
+ expect(answers('GET', ACCEPT_INVITATION_PATH)).toBe(true)
47
+ })
48
+
49
+ it('the page posts where the router listens', () => {
50
+ expect(answers('POST', ACCEPT_INVITATION_PATH)).toBe(true)
51
+ })
52
+
53
+ it('carries the prefix the router adds', () => {
54
+ /*
55
+ * Declaring `/accept-invitation` on this router makes it `/auth/accept-invitation`. The spelling
56
+ * without the prefix is exactly the one that went out, so it is named here as the wrong one.
57
+ */
58
+ expect(ACCEPT_INVITATION_PATH.startsWith('/auth/')).toBe(true)
59
+ expect(answers('GET', '/accept-invitation')).toBe(false)
60
+ })
61
+
62
+ it('is the path the client page uses too', () => {
63
+ const page = readFileSync(
64
+ join(__dirname, '..', '..', 'auth-ui', 'client', 'components', 'accept-invitation.ts'),
65
+ 'utf-8'
66
+ )
67
+
68
+ expect(page).toContain(`return '${ACCEPT_INVITATION_PATH}'`)
69
+ })
70
+ })
71
+
72
+ describe('the other links sent by mail', () => {
73
+ /*
74
+ * Same class, older links. Each controller builds its URL from a literal; every literal has to be
75
+ * a route this router answers. Read from the source because they are not exported — the point is
76
+ * that the link and the route are two spellings of one thing and nothing else ties them.
77
+ */
78
+ const CONTROLLERS = join(__dirname, '..', 'server', 'controllers')
79
+
80
+ const linked = readdirSync(CONTROLLERS)
81
+ .filter(file => file.endsWith('.ts'))
82
+ .flatMap(file =>
83
+ [...readFileSync(join(CONTROLLERS, file), 'utf-8').matchAll(/new URL\(`(\/auth\/[^`?$]*)/g)].map(m => ({
84
+ file,
85
+ path: m[1].replace(/\/$/, '/:token')
86
+ }))
87
+ )
88
+
89
+ it('finds the links it means to check', () => {
90
+ /* A scan that matches nothing passes whatever the links say. */
91
+ expect(linked.map(l => l.file).sort()).toEqual(['reset-password.ts', 'unlock-user.ts', 'verification.ts'])
92
+ })
93
+
94
+ it.each(linked.map(l => [l.file, l.path]))('%s → %s', (_file, path) => {
95
+ expect(answers('GET', path)).toBe(true)
96
+ })
97
+ })
@@ -30,27 +30,18 @@ import { loadCompiled } from './compiled'
30
30
  const { schema: declared } = loadCompiled('service/index')
31
31
  const { privilegeDirectiveResolver, privilegeOfField } = loadCompiled('service/privilege/privilege-directive')
32
32
 
33
- /**
34
- * Open on purpose. Each of these answers about the caller, or about the installation rather
35
- * than about anybody's data.
33
+ /*
34
+ * Open on purpose — read from the module's own declaration (`server/service/open-fields.ts`), which is
35
+ * also what the directive enforces against. It used to be a copy kept here, and two copies of one
36
+ * list are two lists.
36
37
  */
37
- const OPEN_BY_DECISION: Record<string, Record<string, string>> = {
38
- Query: {
39
- /* The caller's own. */
40
- myLoginHistories: 'the caller\'s own sign-ins',
41
- myRoles: 'the caller\'s own roles',
42
- hasPrivilege: 'whether the caller has one — a gate here would need the answer to itself',
43
- domainsWithPrivilege: 'the domains where the caller has one',
44
- checkUserBelongsDomain: 'whether the caller belongs to this domain',
45
- checkResettablePasswordToDefault: 'about the caller\'s own password',
46
- checkDefaultPassword: 'whether the caller is still on the default password',
47
-
48
- /* Neither tenant data nor about a person. */
49
- passwordRule:
50
- 'the installation\'s password policy. The change-password screen needs it before the caller has any privilege — and after a forced reset they may have none',
51
- authProviderTypes: 'which provider types this build supports. A catalogue, not a tenant\'s configuration'
52
- },
53
- Mutation: {}
38
+ const { openFields: declaration } = loadCompiled('service/open-fields')
39
+
40
+ const OPEN_BY_DECISION: Record<string, Record<string, string>> = { Query: {}, Mutation: {} }
41
+
42
+ for (const [where, reason] of Object.entries(declaration.byDesign as Record<string, string>)) {
43
+ const [typeName, field] = where.split('.')
44
+ OPEN_BY_DECISION[typeName][field] = reason
54
45
  }
55
46
 
56
47
  let schema: GraphQLSchema
@@ -58,7 +49,8 @@ let schema: GraphQLSchema
58
49
  beforeAll(async () => {
59
50
  schema = await buildSchema({ resolvers: declared.resolverClasses, validate: false })
60
51
 
61
- privilegeDirectiveResolver(schema)
52
+ /* No modules: this file measures what is open, not what would be refused. */
53
+ privilegeDirectiveResolver(schema, { modules: [] })
62
54
  })
63
55
 
64
56
  function openFields(typeName: 'Query' | 'Mutation'): string[] {
@@ -0,0 +1,215 @@
1
+ import { buildSchema } from 'type-graphql'
2
+ import { graphql, GraphQLSchema } from 'graphql'
3
+ import { makeExecutableSchema } from '@graphql-tools/schema'
4
+
5
+ import { loadCompiled } from './compiled'
6
+
7
+ const { schema: declared } = loadCompiled('service/index')
8
+ const { privilegeDirectiveResolver, privilegeDirectiveTypeDefs } = loadCompiled('service/privilege/privilege-directive')
9
+ const { coverageOf, judgeUndeclared } = loadCompiled('service/privilege/open-field-rule')
10
+
11
+ /**
12
+ * A field that declares no `@privilege` and that its module does not list — what happens to it
13
+ * (`docs/design/auth-rebuild.md` §8 step 1; question sheet Q1 · Q2 · Q5).
14
+ *
15
+ * ── The four answers ────────────────────────────────────────────────────────
16
+ * byDesign open, and the module says why
17
+ * notYetDeclared open, and on the module's list to close
18
+ * unlisted the module opted in and says nothing: boot refuses in development, the field
19
+ * refuses in production
20
+ * not opted in the module declared no lists at all: exactly as before
21
+ *
22
+ * The last one is deliberate. The four products serve resolvers of their own from another
23
+ * repository, and enforcing on them the day this lands would stop all four from booting.
24
+ *
25
+ * These build auth-base's real schema from the `resolverClasses` the server boots from, and vary
26
+ * only the module's declaration — so what is tested is the directive against real fields, not
27
+ * against a model of them.
28
+ */
29
+
30
+ const MODULE = 'auth-base'
31
+ const REAL = declared.openFields
32
+
33
+ function modules(openFields: any) {
34
+ return [{ name: MODULE, schema: { resolverClasses: declared.resolverClasses, openFields } }]
35
+ }
36
+
37
+ /** auth-base's declaration with one reason taken out — the shape of a field somebody forgot to list. */
38
+ function without(where: string) {
39
+ const byDesign = { ...REAL.byDesign }
40
+ delete byDesign[where]
41
+ return { ...REAL, byDesign }
42
+ }
43
+
44
+ async function built(): Promise<GraphQLSchema> {
45
+ return await buildSchema({ resolvers: declared.resolverClasses, validate: false })
46
+ }
47
+
48
+ async function refusalCodeOf(promise: Promise<any>): Promise<string> {
49
+ const result = await promise
50
+ return result?.errors?.[0]?.extensions?.refusal?.code || result?.errors?.[0]?.extensions?.code || 'no error'
51
+ }
52
+
53
+ describe('who is covered', () => {
54
+ it('attributes each root field to the module whose class declared it', async () => {
55
+ const coverage = coverageOf(modules(REAL))
56
+
57
+ expect(coverage.get('Query.myRoles')).toEqual({ module: MODULE, listed: 'by-design' })
58
+ expect(coverage.get('Mutation.createRole')).toEqual({ module: MODULE, listed: null })
59
+ })
60
+
61
+ it('covers nothing for a module that declared no lists', () => {
62
+ /* ⚠ The line that keeps the four products booting. */
63
+ expect(coverageOf(modules(undefined)).size).toBe(0)
64
+ })
65
+
66
+ it('does not cover a field of a class the module did not name', () => {
67
+ const coverage = coverageOf([{ name: 'other', schema: { resolverClasses: [], openFields: {} } }])
68
+
69
+ expect(judgeUndeclared('Query.myRoles', coverage)).toEqual('not-opted-in')
70
+ })
71
+ })
72
+
73
+ describe('the verdicts', () => {
74
+ const coverage = () =>
75
+ coverageOf(modules({ byDesign: { 'Query.myRoles': 'mine' }, notYetDeclared: ['Query.passwordRule'] }))
76
+
77
+ it('open by design', () => {
78
+ expect(judgeUndeclared('Query.myRoles', coverage())).toEqual('open-by-design')
79
+ })
80
+
81
+ it('open, not yet declared', () => {
82
+ expect(judgeUndeclared('Query.passwordRule', coverage())).toEqual('open-not-yet-declared')
83
+ })
84
+
85
+ it('unlisted', () => {
86
+ expect(judgeUndeclared('Query.hasPrivilege', coverage())).toEqual('unlisted')
87
+ })
88
+
89
+ it('not opted in', () => {
90
+ expect(judgeUndeclared('Query.somethingElse', coverage())).toEqual('not-opted-in')
91
+ })
92
+ })
93
+
94
+ describe('in development — the schema refuses to build', () => {
95
+ it('builds with the declaration auth-base ships', async () => {
96
+ /* Every undeclared field of auth-base is in `byDesign`; nothing is unlisted today. */
97
+ const schema = await built()
98
+
99
+ expect(() => privilegeDirectiveResolver(schema, { modules: modules(REAL), mode: 'boot' })).not.toThrow()
100
+ })
101
+
102
+ it('names the field that is unlisted', async () => {
103
+ /* ⚠ The case the step exists for: a field arrived and nobody said who may call it. */
104
+ const schema = await built()
105
+
106
+ expect(() => privilegeDirectiveResolver(schema, { modules: modules(without('Query.myRoles')), mode: 'boot' })).toThrow(
107
+ /Query\.myRoles/
108
+ )
109
+ })
110
+
111
+ it('names every unlisted field at once, not the first', async () => {
112
+ const schema = await built()
113
+ const declaration = without('Query.myRoles')
114
+ delete declaration.byDesign['Query.passwordRule']
115
+
116
+ let message = ''
117
+ try {
118
+ privilegeDirectiveResolver(schema, { modules: modules(declaration), mode: 'boot' })
119
+ } catch (e: any) {
120
+ message = e.message
121
+ }
122
+
123
+ expect(message).toMatch(/2 field\(s\)/)
124
+ expect(message).toMatch(/Query\.myRoles/)
125
+ expect(message).toMatch(/Query\.passwordRule/)
126
+ })
127
+
128
+ it('accepts the field once it is listed as not yet declared', async () => {
129
+ const schema = await built()
130
+ const declaration = { ...without('Query.myRoles'), notYetDeclared: ['Query.myRoles'] }
131
+
132
+ expect(() => privilegeDirectiveResolver(schema, { modules: modules(declaration), mode: 'boot' })).not.toThrow()
133
+ })
134
+
135
+ it('leaves a module that declared no lists exactly as it was', async () => {
136
+ const schema = await built()
137
+
138
+ expect(() => privilegeDirectiveResolver(schema, { modules: modules(undefined), mode: 'boot' })).not.toThrow()
139
+ })
140
+ })
141
+
142
+ describe('in production — only that field refuses', () => {
143
+ it('still builds', async () => {
144
+ const schema = await built()
145
+
146
+ expect(() =>
147
+ privilegeDirectiveResolver(schema, { modules: modules(without('Query.passwordRule')), mode: 'request' })
148
+ ).not.toThrow()
149
+ })
150
+
151
+ it('refuses the unlisted field, with a name the screen can read', async () => {
152
+ /*
153
+ * ⚠ Never the other way. An unlisted field in production answers nobody — wrong in the worst case
154
+ * means one field refuses, never one field opens.
155
+ */
156
+ const schema = privilegeDirectiveResolver(await built(), {
157
+ modules: modules(without('Query.passwordRule')),
158
+ mode: 'request'
159
+ })
160
+
161
+ expect(await refusalCodeOf(graphql({ schema, source: '{ passwordRule { lowerCase } }', contextValue: { state: {} } }))).toEqual(
162
+ 'field-declares-no-privilege'
163
+ )
164
+ })
165
+
166
+ it('leaves a listed field answering', async () => {
167
+ const schema = privilegeDirectiveResolver(await built(), {
168
+ modules: modules(without('Query.myRoles')),
169
+ mode: 'request'
170
+ })
171
+
172
+ const result = await graphql({ schema, source: '{ passwordRule { lowerCase } }', contextValue: { state: {} } })
173
+
174
+ expect(result.errors?.[0]?.extensions?.refusal).toBeUndefined()
175
+ })
176
+ })
177
+
178
+ describe('a subscription is stopped where it opens', () => {
179
+ /*
180
+ * ⚠ The directive used to wrap `resolve` only. A declared subscription then connected for anyone
181
+ * and failed on every event — it leaked nothing and refused nothing. Built from SDL because
182
+ * auth-base has no subscription of its own; the directive sees the same field configuration.
183
+ */
184
+ const typeDefs = `
185
+ type Query { ok: Boolean }
186
+ type Subscription {
187
+ guarded: String @privilege(category: "board", privilege: "query")
188
+ }
189
+ `
190
+
191
+ function schemaWith(subscribe: () => any) {
192
+ return privilegeDirectiveResolver(
193
+ makeExecutableSchema({
194
+ typeDefs: [privilegeDirectiveTypeDefs, typeDefs],
195
+ resolvers: { Query: { ok: () => true }, Subscription: { guarded: { subscribe, resolve: (v: any) => v } } }
196
+ }),
197
+ { modules: [] }
198
+ )
199
+ }
200
+
201
+ it('refuses at subscribe for somebody without the privilege', async () => {
202
+ let opened = false
203
+ const schema = schemaWith(() => {
204
+ opened = true
205
+ return (async function* () {})()
206
+ })
207
+
208
+ const field = schema.getSubscriptionType()!.getFields().guarded as any
209
+
210
+ await expect(
211
+ field.subscribe({}, {}, { state: { user: { id: 'u', domains: [] }, domain: { id: 'd' } } }, {} as any)
212
+ ).rejects.toBeTruthy()
213
+ expect(opened).toBe(false)
214
+ })
215
+ })
@@ -35,7 +35,6 @@
35
35
  "field.model": "model",
36
36
  "field.password": "password",
37
37
  "field.privileges": "privileges",
38
- "field.roles": "roles",
39
38
  "field.token": "token",
40
39
  "field.user": "user",
41
40
  "field.user_account": "user account",
@@ -35,7 +35,6 @@
35
35
  "field.model": "モデル",
36
36
  "field.password": "パスワード",
37
37
  "field.privileges": "権限",
38
- "field.roles": "ルール",
39
38
  "field.token": "トークン",
40
39
  "field.user": "ユーザー",
41
40
  "field.user_account": "ユーザー アカウント",
@@ -35,7 +35,6 @@
35
35
  "field.model": "모델",
36
36
  "field.password": "비밀번호",
37
37
  "field.privileges": "권한",
38
- "field.roles": "규칙",
39
38
  "field.token": "토큰",
40
39
  "field.user": "사용자",
41
40
  "field.user_account": "사용자 계정",
@@ -35,7 +35,6 @@
35
35
  "field.model": "Model",
36
36
  "field.password": "Kata laluan",
37
37
  "field.privileges": "Keistimewaan",
38
- "field.roles": "Peranan",
39
38
  "field.token": "Token",
40
39
  "field.user": "Pengguna",
41
40
  "field.user_account": "Akaun pengguna",
@@ -36,7 +36,6 @@
36
36
  "field.model": "模型",
37
37
  "field.password": "密码",
38
38
  "field.privileges": "权限",
39
- "field.roles": "角色",
40
39
  "field.token": "符记",
41
40
  "field.user": "用户",
42
41
  "field.user_account": "用户账号",