@pikku/inspector 0.12.62 → 0.12.64

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,96 @@
1
+ ## 0.12.64
2
+
3
+ ### Patch Changes
4
+
5
+ - 0b1bf53: Refuse better-auth's `admin()` plugin, and point at `ban()`.
6
+
7
+ `admin()` authorizes its endpoints against a `user.role` column while pikku
8
+ authorizes on scopes, so wiring it means running two authorization models and
9
+ projecting one onto the other — coarsely, since any single `admin:users:*` scope
10
+ has to project to `role='admin'` and thereby unlocks every one of its endpoints
11
+ underneath. Pikku dropped that projection when user management moved to
12
+ `@pikku/addon-admin`'s scoped RPCs, so nothing reads the column any more.
13
+
14
+ The inspector now throws when `admin()` appears in a `betterAuth({ plugins })`
15
+ array, naming the replacement:
16
+
17
+ ```ts
18
+ import { ban } from '@pikku/better-auth'
19
+ betterAuth({ plugins: [ban()] })
20
+ ```
21
+
22
+ `ban()` keeps the one capability `admin()` had that pikku cannot supply from
23
+ outside better-auth: the `banned`/`banReason`/`banExpires` columns and the
24
+ session hook that refuses a banned user a session.
25
+
26
+ `pikku validate` reports the same thing without a prebuild, and additionally
27
+ warns about the quieter half of the migration — an app that dropped `admin()`
28
+ and never wired `ban()`, which keeps its ban columns and its ban UI while
29
+ silently enforcing nothing.
30
+
31
+ Both resolve the plugin's provenance before applying the policy: the entry has
32
+ to be better-auth's `admin`, imported from `better-auth/plugins` — by name, by
33
+ alias or through a namespace — and actually present in the `plugins` array. A
34
+ project's own helper called `admin` passes, and an import left behind after the
35
+ call was removed configures nothing.
36
+
37
+ - Updated dependencies [0b1bf53]
38
+ - @pikku/core@0.12.94
39
+
40
+ ## 0.12.63
41
+
42
+ ### Patch Changes
43
+
44
+ - 274cab3: The singleton intersection moves to the leaves that name it, the runtime stops
45
+ creating schema, and `db generate` writes only the runtime tables a project's
46
+ services own
47
+
48
+ `WiredSingletonServices` was exported from the generated function leaf so the
49
+ auth leaf could import it. Nothing outside a generated leaf ever names it —
50
+ emit declarations for a project of any size and it appears in none of them —
51
+ so the auth and middleware leaves derive the intersection themselves and the
52
+ function leaf keeps it private. `WiredServices` stays exported: 147 `.d.ts`
53
+ files name it, and unexporting it asks every wired module to name each member
54
+ service through a specifier it does not have.
55
+
56
+ `ensurePikkuSchema` is gone. `requirePikkuSchema` replaces it: a service calls
57
+ it at boot, it looks, and it issues no DDL at all. `pikku db generate` writes
58
+ the declaration down as a migration and `pikku db migrate` applies it, and
59
+ those two are now the only way pikku's runtime tables come into existence. A
60
+ service that finds them missing says so and stops, naming both commands.
61
+ Half-present is no longer a distinct case — the remedy is the same migration
62
+ either way. `audit` and `virtual-user` join `pikkuSchemas` as a consequence:
63
+ boot was the only thing that had ever created them.
64
+
65
+ `pikku db generate` applied all of `pikkuSchemas`, so a project with no agents,
66
+ no channels and no workflows still had `agent_threads`, `channels` and
67
+ `workflow_runs` written into its migrations, and then carried them forever. A
68
+ schema now names the services that own it, and generation gates on
69
+ `requiredServices` — the set the inspector already builds for service
70
+ tree-shaking. The gate is one-sided: a schema that names no owner is always
71
+ written, because the session and secret stores and the deployment record are
72
+ reached by the runtime itself and nothing in a project's source implies them.
73
+ Declared scopes now imply `scopeService`, which nothing destructures because
74
+ the generated auth layer is what reaches it.
75
+
76
+ Drift keeps asking the unscoped question. A table already in a database has to
77
+ stay recognisable as a runtime table after the service that needed it is
78
+ dropped — scoping it there would report those tables as unexplained.
79
+
80
+ Every project in this repo that had been relying on boot-time creation now says
81
+ where its tables come from. `createConfig` moves into its own `config.ts` in the
82
+ templates — `pikku db` looks for it there — and the three postgres templates plus
83
+ the workflow verifier declare `postgresUrl` and run `pikku db generate && pikku
84
+ db migrate` before the server starts, from the single connection string their
85
+ runtime opens. The e2e harness cannot: its databases are in-memory sqlite built
86
+ inside the services factory, so nothing outside the process can migrate them. It
87
+ applies the schemas it owns with `applyPikkuSchemas` instead — the same DDL, run
88
+ by the one process that has the database.
89
+
90
+ - Updated dependencies [32616af]
91
+ - Updated dependencies [6848cd9]
92
+ - @pikku/core@0.12.89
93
+
1
94
  ## 0.12.62
2
95
 
3
96
  ### Patch Changes
@@ -60,15 +60,52 @@ const readArrayProp = (obj, name) => {
60
60
  return prop.initializer;
61
61
  return undefined;
62
62
  };
63
+ /** better-auth's own plugin entrypoint. */
64
+ const BETTER_AUTH_PLUGINS_MODULE = 'better-auth/plugins';
65
+ const readPluginBindings = (source) => {
66
+ const named = new Map();
67
+ const namespaces = new Set();
68
+ for (const statement of source.statements) {
69
+ if (!ts.isImportDeclaration(statement) ||
70
+ !ts.isStringLiteral(statement.moduleSpecifier) ||
71
+ statement.moduleSpecifier.text !== BETTER_AUTH_PLUGINS_MODULE) {
72
+ continue;
73
+ }
74
+ const bindings = statement.importClause?.namedBindings;
75
+ if (!bindings)
76
+ continue;
77
+ if (ts.isNamespaceImport(bindings)) {
78
+ namespaces.add(bindings.name.text);
79
+ continue;
80
+ }
81
+ for (const element of bindings.elements) {
82
+ named.set(element.name.text, (element.propertyName ?? element.name).text);
83
+ }
84
+ }
85
+ return { named, namespaces };
86
+ };
63
87
  /**
64
- * Read the callee name of a `plugins: [...]` entry. better-auth plugins are
65
- * factory calls (`bearer()`, `twoFactor({ ... })`, `ban()`); the entry's id
66
- * is the called function's name. Member-expression callees (`foo.bar()`) and
67
- * non-call entries are ignored.
88
+ * Read a `plugins: [...]` entry. better-auth plugins are factory calls —
89
+ * `bearer()`, `twoFactor({ ... })`, `ban()`, or `plugins.bearer()` through a
90
+ * namespace import; non-call entries are ignored. `betterAuthExport` is the
91
+ * name better-auth exports the plugin under, and is absent for a plugin that
92
+ * came from somewhere else, which no policy here applies to.
68
93
  */
69
- const readPluginId = (el) => {
70
- if (ts.isCallExpression(el) && ts.isIdentifier(el.expression))
71
- return el.expression.text;
94
+ const readPluginEntry = (el, bindings) => {
95
+ if (!ts.isCallExpression(el))
96
+ return undefined;
97
+ const callee = el.expression;
98
+ if (ts.isIdentifier(callee)) {
99
+ return {
100
+ id: callee.text,
101
+ betterAuthExport: bindings.named.get(callee.text),
102
+ };
103
+ }
104
+ if (ts.isPropertyAccessExpression(callee) &&
105
+ ts.isIdentifier(callee.expression) &&
106
+ bindings.namespaces.has(callee.expression.text)) {
107
+ return { id: callee.name.text, betterAuthExport: callee.name.text };
108
+ }
72
109
  return undefined;
73
110
  };
74
111
  /**
@@ -101,6 +138,38 @@ const isInsideGlobalMiddlewareRegistration = (node) => {
101
138
  }
102
139
  return false;
103
140
  };
141
+ /**
142
+ * Refusal for better-auth's `admin()`, which pikku does not support.
143
+ *
144
+ * `admin()` authorizes its endpoints against a `user.role` column, so wiring it
145
+ * means running a second authorization model alongside pikku's scopes and
146
+ * keeping one projected onto the other — coarsely, since any single
147
+ * `admin:users:*` scope has to project to `role='admin'` and thereby unlocks
148
+ * every one of its endpoints underneath. Pikku dropped that projection: user
149
+ * management is `@pikku/addon-admin`'s scoped RPCs, and the one capability
150
+ * `admin()` had that nothing else did — refusing a session to a banned user —
151
+ * is `ban()`.
152
+ *
153
+ * Thrown rather than warned because the failure it prevents is silent. An app
154
+ * that drops `admin()` without wiring `ban()` keeps its ban columns and its ban
155
+ * UI, and simply stops enforcing bans.
156
+ */
157
+ const UNSUPPORTED_ADMIN_PLUGIN = (sourceFile) => `better-auth's admin() plugin is not supported by pikku (${sourceFile}).
158
+
159
+ It authorizes against a 'user.role' column, while pikku authorizes on scopes —
160
+ wiring both means one has to be projected onto the other, and the projection is
161
+ coarser than the scopes it stands in for.
162
+
163
+ Use ban() instead:
164
+
165
+ import { ban } from '@pikku/better-auth'
166
+ betterAuth({ plugins: [ban()] })
167
+
168
+ ban() keeps the part of admin() that pikku cannot supply from outside
169
+ better-auth — the banned/banReason/banExpires columns and the session hook that
170
+ refuses a banned user a session. Everything else admin() offered is already
171
+ scoped RPCs in @pikku/addon-admin: list, create, ban, remove, revoke sessions
172
+ and set password.`;
104
173
  /**
105
174
  * Detects `pikkuBetterAuth((services) => betterAuth({...}))` calls.
106
175
  *
@@ -234,10 +303,16 @@ export const addAuth = (logger, node, _checker, state) => {
234
303
  }
235
304
  const plugins = readArrayProp(config, 'plugins');
236
305
  if (plugins) {
306
+ const bindings = readPluginBindings(node.getSourceFile());
237
307
  for (const el of plugins.elements) {
238
- const id = readPluginId(el);
239
- if (id && !state.auth.plugins.includes(id)) {
240
- state.auth.plugins.push(id);
308
+ const entry = readPluginEntry(el, bindings);
309
+ if (!entry)
310
+ continue;
311
+ if (entry.betterAuthExport === 'admin') {
312
+ throw new Error(UNSUPPORTED_ADMIN_PLUGIN(sourceFile));
313
+ }
314
+ if (!state.auth.plugins.includes(entry.id)) {
315
+ state.auth.plugins.push(entry.id);
241
316
  }
242
317
  }
243
318
  }
@@ -266,6 +266,12 @@ export function aggregateRequiredServices(state) {
266
266
  if (Object.keys(state.channels.meta).length > 0) {
267
267
  requiredServices.add('eventHub');
268
268
  }
269
+ // Declared scopes need scopeService to resolve a grant. Nothing in a project
270
+ // destructures it — the generated auth layer reaches it — so the declaration
271
+ // is the only signal there is.
272
+ if ((state.scopes?.definitions?.length ?? 0) > 0) {
273
+ requiredServices.add('scopeService');
274
+ }
269
275
  // 7. Services that consumed addons need from the parent project.
270
276
  const addonFnServices = new Map();
271
277
  for (const [namespace, fns] of Object.entries(state.addonFunctions ?? {})) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.62",
3
+ "version": "0.12.64",
4
4
  "description": "TypeScript inspector for Pikku — discovers functions and wirings to drive code generation",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "BUSL-1.1",
@@ -45,10 +45,10 @@
45
45
  "zod-to-ts": "^2.1.0"
46
46
  },
47
47
  "peerDependencies": {
48
- "@pikku/core": "^0.12.87"
48
+ "@pikku/core": "^0.12.94"
49
49
  },
50
50
  "devDependencies": {
51
- "@pikku/core": "^0.12.87",
51
+ "@pikku/core": "^0.12.94",
52
52
  "@types/node": "^24.13.3"
53
53
  },
54
54
  "engines": {
@@ -217,6 +217,126 @@ describe('addAuth inspector', () => {
217
217
  }
218
218
  })
219
219
 
220
+ test('refuses better-auth admin() and points at ban()', async () => {
221
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-admin-'))
222
+ const file = join(rootDir, 'auth.ts')
223
+
224
+ await writeFile(
225
+ file,
226
+ [
227
+ "import { pikkuBetterAuth } from '@pikku/better-auth'",
228
+ "import { betterAuth } from 'better-auth'",
229
+ "import { admin, bearer } from 'better-auth/plugins'",
230
+ 'export const auth = pikkuBetterAuth(() =>',
231
+ ' betterAuth({',
232
+ ' plugins: [bearer(), admin()],',
233
+ ' })',
234
+ ')',
235
+ ].join('\n')
236
+ )
237
+
238
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
239
+ try {
240
+ await assert.rejects(
241
+ () => inspect(makeLogger(criticals), [file], { rootDir }),
242
+ (error: Error) => {
243
+ assert.match(error.message, /admin\(\) plugin is not supported/)
244
+ assert.match(error.message, /ban\(\)/)
245
+ assert.match(error.message, /@pikku\/addon-admin/)
246
+ return true
247
+ }
248
+ )
249
+ } finally {
250
+ await rm(rootDir, { recursive: true, force: true })
251
+ }
252
+ })
253
+
254
+ test('refuses admin() reached through a namespace import', async () => {
255
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-ns-admin-'))
256
+ const file = join(rootDir, 'auth.ts')
257
+
258
+ await writeFile(
259
+ file,
260
+ [
261
+ "import { pikkuBetterAuth } from '@pikku/better-auth'",
262
+ "import { betterAuth } from 'better-auth'",
263
+ "import * as plugins from 'better-auth/plugins'",
264
+ 'export const auth = pikkuBetterAuth(() =>',
265
+ ' betterAuth({',
266
+ ' plugins: [plugins.bearer(), plugins.admin()],',
267
+ ' })',
268
+ ')',
269
+ ].join('\n')
270
+ )
271
+
272
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
273
+ try {
274
+ await assert.rejects(
275
+ () => inspect(makeLogger(criticals), [file], { rootDir }),
276
+ /admin\(\) plugin is not supported/
277
+ )
278
+ } finally {
279
+ await rm(rootDir, { recursive: true, force: true })
280
+ }
281
+ })
282
+
283
+ test('allows a local plugin of its own named admin', async () => {
284
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-local-admin-'))
285
+ const file = join(rootDir, 'auth.ts')
286
+
287
+ await writeFile(
288
+ file,
289
+ [
290
+ "import { pikkuBetterAuth } from '@pikku/better-auth'",
291
+ "import { betterAuth } from 'better-auth'",
292
+ "import { bearer } from 'better-auth/plugins'",
293
+ 'const admin = () => ({ id: "admin" })',
294
+ 'export const auth = pikkuBetterAuth(() =>',
295
+ ' betterAuth({',
296
+ ' plugins: [bearer(), admin()],',
297
+ ' })',
298
+ ')',
299
+ ].join('\n')
300
+ )
301
+
302
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
303
+ try {
304
+ const state = await inspect(makeLogger(criticals), [file], { rootDir })
305
+ assert.equal(criticals.length, 0)
306
+ assert.deepEqual(state.auth.plugins, ['bearer', 'admin'])
307
+ } finally {
308
+ await rm(rootDir, { recursive: true, force: true })
309
+ }
310
+ })
311
+
312
+ test('records a namespace-imported plugin under its exported name', async () => {
313
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-ns-'))
314
+ const file = join(rootDir, 'auth.ts')
315
+
316
+ await writeFile(
317
+ file,
318
+ [
319
+ "import { pikkuBetterAuth } from '@pikku/better-auth'",
320
+ "import { betterAuth } from 'better-auth'",
321
+ "import * as plugins from 'better-auth/plugins'",
322
+ 'export const auth = pikkuBetterAuth(() =>',
323
+ ' betterAuth({',
324
+ ' plugins: [plugins.bearer()],',
325
+ ' })',
326
+ ')',
327
+ ].join('\n')
328
+ )
329
+
330
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
331
+ try {
332
+ const state = await inspect(makeLogger(criticals), [file], { rootDir })
333
+ assert.equal(criticals.length, 0)
334
+ assert.deepEqual(state.auth.plugins, ['bearer'])
335
+ } finally {
336
+ await rm(rootDir, { recursive: true, force: true })
337
+ }
338
+ })
339
+
220
340
  test('records no plugins when the plugins array is absent', async () => {
221
341
  const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-noplugins-'))
222
342
  const file = join(rootDir, 'auth.ts')
@@ -87,15 +87,71 @@ const readArrayProp = (
87
87
  return undefined
88
88
  }
89
89
 
90
+ /** better-auth's own plugin entrypoint. */
91
+ const BETTER_AUTH_PLUGINS_MODULE = 'better-auth/plugins'
92
+
93
+ /**
94
+ * What the names used in a `plugins: [...]` array were bound to by the file's
95
+ * imports of {@link BETTER_AUTH_PLUGINS_MODULE}, so a project's local helper
96
+ * called `admin` is not mistaken for better-auth's.
97
+ */
98
+ type PluginBindings = {
99
+ /** Local name → the name it is exported under by better-auth. */
100
+ named: Map<string, string>
101
+ /** Locals bound by `import * as x from 'better-auth/plugins'`. */
102
+ namespaces: Set<string>
103
+ }
104
+
105
+ const readPluginBindings = (source: ts.SourceFile): PluginBindings => {
106
+ const named = new Map<string, string>()
107
+ const namespaces = new Set<string>()
108
+ for (const statement of source.statements) {
109
+ if (
110
+ !ts.isImportDeclaration(statement) ||
111
+ !ts.isStringLiteral(statement.moduleSpecifier) ||
112
+ statement.moduleSpecifier.text !== BETTER_AUTH_PLUGINS_MODULE
113
+ ) {
114
+ continue
115
+ }
116
+ const bindings = statement.importClause?.namedBindings
117
+ if (!bindings) continue
118
+ if (ts.isNamespaceImport(bindings)) {
119
+ namespaces.add(bindings.name.text)
120
+ continue
121
+ }
122
+ for (const element of bindings.elements) {
123
+ named.set(element.name.text, (element.propertyName ?? element.name).text)
124
+ }
125
+ }
126
+ return { named, namespaces }
127
+ }
128
+
90
129
  /**
91
- * Read the callee name of a `plugins: [...]` entry. better-auth plugins are
92
- * factory calls (`bearer()`, `twoFactor({ ... })`, `ban()`); the entry's id
93
- * is the called function's name. Member-expression callees (`foo.bar()`) and
94
- * non-call entries are ignored.
130
+ * Read a `plugins: [...]` entry. better-auth plugins are factory calls —
131
+ * `bearer()`, `twoFactor({ ... })`, `ban()`, or `plugins.bearer()` through a
132
+ * namespace import; non-call entries are ignored. `betterAuthExport` is the
133
+ * name better-auth exports the plugin under, and is absent for a plugin that
134
+ * came from somewhere else, which no policy here applies to.
95
135
  */
96
- const readPluginId = (el: ts.Expression): string | undefined => {
97
- if (ts.isCallExpression(el) && ts.isIdentifier(el.expression))
98
- return el.expression.text
136
+ const readPluginEntry = (
137
+ el: ts.Expression,
138
+ bindings: PluginBindings
139
+ ): { id: string; betterAuthExport?: string } | undefined => {
140
+ if (!ts.isCallExpression(el)) return undefined
141
+ const callee = el.expression
142
+ if (ts.isIdentifier(callee)) {
143
+ return {
144
+ id: callee.text,
145
+ betterAuthExport: bindings.named.get(callee.text),
146
+ }
147
+ }
148
+ if (
149
+ ts.isPropertyAccessExpression(callee) &&
150
+ ts.isIdentifier(callee.expression) &&
151
+ bindings.namespaces.has(callee.expression.text)
152
+ ) {
153
+ return { id: callee.name.text, betterAuthExport: callee.name.text }
154
+ }
99
155
  return undefined
100
156
  }
101
157
 
@@ -128,6 +184,40 @@ const isInsideGlobalMiddlewareRegistration = (node: ts.Node): boolean => {
128
184
  return false
129
185
  }
130
186
 
187
+ /**
188
+ * Refusal for better-auth's `admin()`, which pikku does not support.
189
+ *
190
+ * `admin()` authorizes its endpoints against a `user.role` column, so wiring it
191
+ * means running a second authorization model alongside pikku's scopes and
192
+ * keeping one projected onto the other — coarsely, since any single
193
+ * `admin:users:*` scope has to project to `role='admin'` and thereby unlocks
194
+ * every one of its endpoints underneath. Pikku dropped that projection: user
195
+ * management is `@pikku/addon-admin`'s scoped RPCs, and the one capability
196
+ * `admin()` had that nothing else did — refusing a session to a banned user —
197
+ * is `ban()`.
198
+ *
199
+ * Thrown rather than warned because the failure it prevents is silent. An app
200
+ * that drops `admin()` without wiring `ban()` keeps its ban columns and its ban
201
+ * UI, and simply stops enforcing bans.
202
+ */
203
+ const UNSUPPORTED_ADMIN_PLUGIN = (sourceFile: string): string =>
204
+ `better-auth's admin() plugin is not supported by pikku (${sourceFile}).
205
+
206
+ It authorizes against a 'user.role' column, while pikku authorizes on scopes —
207
+ wiring both means one has to be projected onto the other, and the projection is
208
+ coarser than the scopes it stands in for.
209
+
210
+ Use ban() instead:
211
+
212
+ import { ban } from '@pikku/better-auth'
213
+ betterAuth({ plugins: [ban()] })
214
+
215
+ ban() keeps the part of admin() that pikku cannot supply from outside
216
+ better-auth — the banned/banReason/banExpires columns and the session hook that
217
+ refuses a banned user a session. Everything else admin() offered is already
218
+ scoped RPCs in @pikku/addon-admin: list, create, ban, remove, revoke sessions
219
+ and set password.`
220
+
131
221
  /**
132
222
  * Detects `pikkuBetterAuth((services) => betterAuth({...}))` calls.
133
223
  *
@@ -304,10 +394,15 @@ export const addAuth: AddWiring = (logger, node, _checker, state) => {
304
394
 
305
395
  const plugins = readArrayProp(config, 'plugins')
306
396
  if (plugins) {
397
+ const bindings = readPluginBindings(node.getSourceFile())
307
398
  for (const el of plugins.elements) {
308
- const id = readPluginId(el)
309
- if (id && !state.auth.plugins.includes(id)) {
310
- state.auth.plugins.push(id)
399
+ const entry = readPluginEntry(el, bindings)
400
+ if (!entry) continue
401
+ if (entry.betterAuthExport === 'admin') {
402
+ throw new Error(UNSUPPORTED_ADMIN_PLUGIN(sourceFile))
403
+ }
404
+ if (!state.auth.plugins.includes(entry.id)) {
405
+ state.auth.plugins.push(entry.id)
311
406
  }
312
407
  }
313
408
  }
@@ -94,6 +94,7 @@ function makeState(
94
94
  addonRequiredParentServices?: string[]
95
95
  authServices?: string[]
96
96
  graphMeta?: Record<string, any>
97
+ scopeDefinitions?: any[]
97
98
  } = {}
98
99
  ): Omit<InspectorState, 'typesLookup'> {
99
100
  return {
@@ -128,6 +129,7 @@ function makeState(
128
129
  workflows: { meta: {}, graphMeta: overrides.graphMeta ?? {} },
129
130
  wireServicesMeta: new Map(),
130
131
  rpc: { internalMeta: {}, exposedMeta: {} },
132
+ scopes: { definitions: overrides.scopeDefinitions ?? [] },
131
133
  addonFunctions: overrides.addonFunctions ?? {},
132
134
  addonRequiredParentServices: overrides.addonRequiredParentServices ?? [],
133
135
  auth: overrides.authServices
@@ -392,6 +394,24 @@ describe('aggregateRequiredServices — the auth factory’s own services', () =
392
394
  })
393
395
  })
394
396
 
397
+ describe('aggregateRequiredServices — scopes imply the service that resolves them', () => {
398
+ // Nothing in a project destructures `scopeService` — the generated auth layer
399
+ // reaches it — so the declaration is the only signal there is. Without this,
400
+ // `pikku db generate` would leave a project that declares scopes with no
401
+ // tables to grant them in.
402
+ test('a declared scope requires scopeService', () => {
403
+ const state = makeState({ scopeDefinitions: [{ id: 'admin' }] })
404
+ aggregateRequiredServices(state)
405
+ assert.ok(state.serviceAggregation.requiredServices.has('scopeService'))
406
+ })
407
+
408
+ test('a project that declares none does not', () => {
409
+ const state = makeState()
410
+ aggregateRequiredServices(state)
411
+ assert.ok(!state.serviceAggregation.requiredServices.has('scopeService'))
412
+ })
413
+ })
414
+
395
415
  describe('override validation resolves the override target (value), not the logical key', () => {
396
416
  test('validateSecretOverrides accepts an override whose value is a declared secret', () => {
397
417
  const { logger, criticals } = makeCriticalLogger()
@@ -322,6 +322,13 @@ export function aggregateRequiredServices(
322
322
  requiredServices.add('eventHub')
323
323
  }
324
324
 
325
+ // Declared scopes need scopeService to resolve a grant. Nothing in a project
326
+ // destructures it — the generated auth layer reaches it — so the declaration
327
+ // is the only signal there is.
328
+ if ((state.scopes?.definitions?.length ?? 0) > 0) {
329
+ requiredServices.add('scopeService')
330
+ }
331
+
325
332
  // 7. Services that consumed addons need from the parent project.
326
333
  const addonFnServices = new Map<string, string[] | undefined>()
327
334
  for (const [namespace, fns] of Object.entries(state.addonFunctions ?? {})) {