@pikku/kysely 0.13.0 → 0.13.2

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,120 @@
1
+ ## 0.13.2
2
+
3
+ ### Patch Changes
4
+
5
+ - 4324652: Scope AI agent thread reads to the calling session.
6
+
7
+ The generated thread-management functions (`getAgentThreads`,
8
+ `getAgentThreadMessages`, `getAgentThreadRuns`, `deleteAgentThread`) keyed purely
9
+ off a caller-supplied `threadId` and treated `resourceId` as an optional filter,
10
+ so omitting it enumerated every tenant's threads.
11
+ - `listThreads` gains an `owners` **authorization constraint** (distinct from the
12
+ `resourceId` filter): an empty array matches nothing, and it is always derived
13
+ from the session, never from input. Implemented across the Kysely, Redis and
14
+ MongoDB agent run services, with LIKE/regex metacharacter escaping so an owner
15
+ id containing `_` or `%` cannot match a foreign owner.
16
+ - The three `threadId`-keyed functions are now guarded by an `isThreadOwner`
17
+ `pikkuPermission` rather than an in-body check. A thread that does not exist is
18
+ denied rather than 404'd, so it is indistinguishable from one owned by someone
19
+ else.
20
+ - New `@pikku/core/ai-agent` helpers: `canAccessThread`, `threadOwnerConstraint`,
21
+ `sessionPrincipals`, `isOwnedByPrincipal`.
22
+
23
+ Services destructured by a wired function are now non-optional inside it.
24
+
25
+ The inspector already aggregated the services used by every wired `func`,
26
+ `permissions` and `middleware` into `RequiredSingletonServices`, but the
27
+ generated function types defaulted their service parameter to the raw `Services`
28
+ — so a service declared `foo?: Foo` still arrived as possibly-undefined, forcing
29
+ `if (!foo) throw new MissingServiceError(...)` guards that could never fire.
30
+ Generated types now expose `WiredSingletonServices` / `WiredServices`
31
+ (`RequiredSingletonServices & Services`) and default the `RequiredServices`
32
+ generic of functions, permissions, middleware, auth and approval-description
33
+ helpers to them. Optionality now means only what it should: "this service may
34
+ not be created, because nothing uses it".
35
+
36
+ - Updated dependencies [5f19016]
37
+ - Updated dependencies [78e4778]
38
+ - Updated dependencies [4324652]
39
+ - Updated dependencies [de044f8]
40
+ - Updated dependencies [cd1a811]
41
+ - Updated dependencies [19fa6f0]
42
+ - Updated dependencies [b501612]
43
+ - Updated dependencies [eb37b1e]
44
+ - @pikku/core@0.12.66
45
+
46
+ ## 0.13.1
47
+
48
+ ### Patch Changes
49
+
50
+ - 13474a6: feat(scopes): grant scopes directly to a user, not only through roles
51
+
52
+ A scope can now be granted to a user directly, outside of any role.
53
+ `resolveScopes` returns the union of a user's role-derived scopes and their
54
+ direct grants, so a one-off capability no longer requires inventing a role.
55
+ - `@pikku/core`: `ScopeService` gains `addScopeToUser` / `removeScopeFromUser` /
56
+ `listUserScopes`.
57
+ - `@pikku/kysely`: a new `pikku_user_scope` table (FK into `pikku_scopes`, so the
58
+ database still refuses an undeclared grant; `ON DELETE CASCADE` from `user`,
59
+ so deleting a user takes their direct grants with it). `resolveScopes` unions
60
+ it with the role join.
61
+ - `@pikku/addon-console`: `scopeAddScopeToUser` / `scopeRemoveScopeFromUser`
62
+ (gated by `pikku:scopes:manage`), and `scopeListUserRoles` now also returns
63
+ `directScopes`.
64
+ - `@pikku/console`: a **Direct scopes** section in the user roles drawer to grant
65
+ and revoke scopes directly, showing them distinctly from the resolved union.
66
+
67
+ Also: the Scopes page now distinguishes a permission error (a console admin
68
+ without `pikku:scopes:read`) from an actual scope-service outage, instead of
69
+ showing "the scope service may be unavailable" for both.
70
+
71
+ - 13474a6: feat: KyselyScopeService — resolve and administer user scopes
72
+
73
+ Adds `KyselyScopeService`, backing the core `ScopeService` interface with four
74
+ self-created tables: `pikku_scopes`, `pikku_roles`, `pikku_role_scopes` and
75
+ `pikku_user_role`.
76
+
77
+ Scopes are declared in code and synced additively — a scope that is no longer
78
+ declared is marked, never deleted, so a rename or a rolling deploy cannot
79
+ silently revoke a grant. `pikku scopes prune` is the deliberate removal path.
80
+ Roles are data, composed by admins at runtime, and `pikku_role_scopes` has a
81
+ foreign key into `pikku_scopes`, so the database itself refuses to grant a
82
+ scope that was never declared.
83
+
84
+ - 13474a6: feat: ScopeService.listScopes
85
+
86
+ Exposes the scope vocabulary held in the store — everything a role can be
87
+ composed from — flagging any scope that is still present but no longer declared
88
+ in code (inert, and awaiting `pikku scopes prune`).
89
+
90
+ - 70fa400: Add outgoing webhooks — `webhookService.send()` enqueues signed deliveries onto a retrying queue, `@pikku/kysely`'s `KyselyWebhookService` persists per-attempt delivery history, and `@pikku/console` gains a read-only `/webhooks` page; also caches resolved secrets in `TypedSecretService` and registers inline-`func` metadata for queue/scheduler/trigger/gateway wirings.
91
+ - Updated dependencies [7ab5287]
92
+ - Updated dependencies [e86bc17]
93
+ - Updated dependencies [a9b96a0]
94
+ - Updated dependencies [3f7fc54]
95
+ - Updated dependencies [c478794]
96
+ - Updated dependencies [3f04ae4]
97
+ - Updated dependencies [90d9f04]
98
+ - Updated dependencies [cb079cc]
99
+ - Updated dependencies [cb079cc]
100
+ - Updated dependencies [0a7db82]
101
+ - Updated dependencies [981c4db]
102
+ - Updated dependencies [13474a6]
103
+ - Updated dependencies [5a2b0d5]
104
+ - Updated dependencies [13474a6]
105
+ - Updated dependencies [ee040dc]
106
+ - Updated dependencies [cb079cc]
107
+ - Updated dependencies [13474a6]
108
+ - Updated dependencies [9f0d0eb]
109
+ - Updated dependencies [13474a6]
110
+ - Updated dependencies [70fa400]
111
+ - Updated dependencies [7b2ea23]
112
+ - Updated dependencies [1dc77d5]
113
+ - Updated dependencies [416606c]
114
+ - Updated dependencies [d2a6eea]
115
+ - Updated dependencies [30e62ee]
116
+ - @pikku/core@0.12.64
117
+
1
118
  ## 0.13.0
2
119
 
3
120
  ### Minor Changes
@@ -12,6 +12,8 @@ export type { KyselySecretServiceConfig } from './kysely-secret-service.js';
12
12
  export { KyselyCredentialService } from './kysely-credential-service.js';
13
13
  export type { KyselyCredentialServiceConfig } from './kysely-credential-service.js';
14
14
  export { KyselySessionStore } from './kysely-session-store.js';
15
+ export { KyselyScopeService } from './kysely-scope-service.js';
16
+ export { KyselyWebhookService } from './kysely-webhook-service.js';
15
17
  export { createAuditedKysely, type CreateAuditedKyselyOptions, } from './create-audited-kysely.js';
16
18
  export { KyselyAuditService } from './kysely-audit-service.js';
17
19
  export { SerializePlugin, SqliteSerializePlugin, BaseSerializePlugin, defaultSerializer, defaultDeserializer, maybeJson, skipTransform, dateRegex, type Serializer, type Deserializer, type SerializePluginOptions, } from './serialize-plugin.js';
package/dist/src/index.js CHANGED
@@ -10,6 +10,8 @@ export { KyselyAIRunStateService } from './kysely-ai-run-state-service.js';
10
10
  export { KyselySecretService } from './kysely-secret-service.js';
11
11
  export { KyselyCredentialService } from './kysely-credential-service.js';
12
12
  export { KyselySessionStore } from './kysely-session-store.js';
13
+ export { KyselyScopeService } from './kysely-scope-service.js';
14
+ export { KyselyWebhookService } from './kysely-webhook-service.js';
13
15
  export { createAuditedKysely, } from './create-audited-kysely.js';
14
16
  export { KyselyAuditService } from './kysely-audit-service.js';
15
17
  export { SerializePlugin, SqliteSerializePlugin, BaseSerializePlugin, defaultSerializer, defaultDeserializer, maybeJson, skipTransform, dateRegex, } from './serialize-plugin.js';
@@ -7,6 +7,7 @@ export declare class KyselyAgentRunService implements AgentRunService {
7
7
  listThreads(options?: {
8
8
  agentName?: string;
9
9
  resourceId?: string;
10
+ owners?: string[];
10
11
  limit?: number;
11
12
  offset?: number;
12
13
  }): Promise<AIThread[]>;
@@ -1,11 +1,19 @@
1
+ import { sql } from 'kysely';
1
2
  import { parseJson } from './kysely-json.js';
3
+ // Owner ids routinely contain `_` (e.g. `user_123`), which LIKE treats as a
4
+ // single-character wildcard — so a raw prefix would match a foreign owner.
5
+ const escapeLikePattern = (value) => value.replace(/[\\%_]/g, (char) => `\\${char}`);
2
6
  export class KyselyAgentRunService {
3
7
  db;
4
8
  constructor(db) {
5
9
  this.db = db;
6
10
  }
7
11
  async listThreads(options) {
8
- const { agentName, resourceId, limit = 50, offset = 0 } = options ?? {};
12
+ const { agentName, resourceId, owners, limit = 50, offset = 0, } = options ?? {};
13
+ // An owners constraint is an authorization boundary, so an empty list must
14
+ // match nothing rather than degrade to "no filter".
15
+ if (owners && owners.length === 0)
16
+ return [];
9
17
  let query = this.db
10
18
  .selectFrom('aiThreads as t')
11
19
  .select([
@@ -26,6 +34,12 @@ export class KyselyAgentRunService {
26
34
  if (resourceId) {
27
35
  query = query.where('t.resourceId', '=', resourceId);
28
36
  }
37
+ if (owners) {
38
+ query = query.where((eb) => eb.or(owners.flatMap((owner) => [
39
+ eb('t.resourceId', '=', owner),
40
+ eb('t.resourceId', 'like', sql `${`${escapeLikePattern(owner)}:%`} escape '\\'`),
41
+ ])));
42
+ }
29
43
  const result = await query
30
44
  .orderBy('t.updatedAt', 'desc')
31
45
  .limit(limit)
@@ -0,0 +1,59 @@
1
+ import type { Role, ScopeService } from '@pikku/core/services';
2
+ import type { FlatScope } from '@pikku/core/scope';
3
+ import type { Kysely } from 'kysely';
4
+ import type { KyselyPikkuDB } from './kysely-tables.js';
5
+ /**
6
+ * Resolves and administers user scopes against four self-created tables.
7
+ *
8
+ * Requires better-auth: `pikku_user_role.user_id` references its `user` table
9
+ * with ON DELETE CASCADE, so deleting a user takes their grants with it. That
10
+ * table is created by better-auth's own migrations, which `pikku db migrate`
11
+ * hard-fails without, so it exists before `init()` runs.
12
+ *
13
+ * Scopes are declared in code and synced here; roles are data, composed by
14
+ * admins at runtime. `pikku_role_scopes` FKs into `pikku_scopes`, so the
15
+ * database itself refuses to grant a scope that was never declared.
16
+ */
17
+ export declare class KyselyScopeService implements ScopeService {
18
+ private db;
19
+ private initialized;
20
+ constructor(db: Kysely<KyselyPikkuDB>);
21
+ init(): Promise<void>;
22
+ /**
23
+ * Registers the declared scope set.
24
+ *
25
+ * Additive: rows are upserted and anything no longer declared is *marked*
26
+ * (`declared = false`), never deleted. Marking is non-destructive, so a
27
+ * rename, a rollback, or a rolling deploy where an older replica is still
28
+ * serving cannot silently strip a grant. `pruneScopes` is the deliberate
29
+ * removal path.
30
+ */
31
+ syncScopes(scopes: FlatScope[]): Promise<void>;
32
+ resolveScopes(userId: string): Promise<string[]>;
33
+ listScopes(): Promise<Array<FlatScope & {
34
+ declared: boolean;
35
+ }>>;
36
+ createRole(role: Role): Promise<void>;
37
+ deleteRole(name: string): Promise<void>;
38
+ setRoleScopes(name: string, scopes: string[]): Promise<void>;
39
+ listRoles(): Promise<Role[]>;
40
+ addUserToRole(userId: string, role: string, grantedBy?: string): Promise<void>;
41
+ removeUserFromRole(userId: string, role: string): Promise<void>;
42
+ listUserRoles(userId: string): Promise<string[]>;
43
+ addScopeToUser(userId: string, scope: string, grantedBy?: string): Promise<void>;
44
+ removeScopeFromUser(userId: string, scope: string): Promise<void>;
45
+ listUserScopes(userId: string): Promise<string[]>;
46
+ /**
47
+ * Scopes marked undeclared by the last sync, with the roles that would lose
48
+ * them. Powers `pikku scopes audit`.
49
+ */
50
+ findStaleScopes(): Promise<Array<{
51
+ scope: string;
52
+ roles: string[];
53
+ }>>;
54
+ /**
55
+ * Removes undeclared scopes, cascading them out of every role that holds
56
+ * them. This revokes access, so it is never run implicitly.
57
+ */
58
+ pruneScopes(): Promise<string[]>;
59
+ }
@@ -0,0 +1,268 @@
1
+ import { sql } from 'kysely';
2
+ /**
3
+ * Resolves and administers user scopes against four self-created tables.
4
+ *
5
+ * Requires better-auth: `pikku_user_role.user_id` references its `user` table
6
+ * with ON DELETE CASCADE, so deleting a user takes their grants with it. That
7
+ * table is created by better-auth's own migrations, which `pikku db migrate`
8
+ * hard-fails without, so it exists before `init()` runs.
9
+ *
10
+ * Scopes are declared in code and synced here; roles are data, composed by
11
+ * admins at runtime. `pikku_role_scopes` FKs into `pikku_scopes`, so the
12
+ * database itself refuses to grant a scope that was never declared.
13
+ */
14
+ export class KyselyScopeService {
15
+ db;
16
+ initialized = false;
17
+ constructor(db) {
18
+ this.db = db;
19
+ }
20
+ async init() {
21
+ if (this.initialized) {
22
+ return;
23
+ }
24
+ await this.db.schema
25
+ .createTable('pikku_scopes')
26
+ .ifNotExists()
27
+ .addColumn('name', 'text', (col) => col.primaryKey())
28
+ .addColumn('description', 'text')
29
+ .addColumn('declared', 'boolean', (col) => col.defaultTo(true).notNull())
30
+ .execute();
31
+ await this.db.schema
32
+ .createTable('pikku_roles')
33
+ .ifNotExists()
34
+ .addColumn('name', 'text', (col) => col.primaryKey())
35
+ .addColumn('description', 'text')
36
+ .addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
37
+ .execute();
38
+ await this.db.schema
39
+ .createTable('pikku_role_scopes')
40
+ .ifNotExists()
41
+ .addColumn('role', 'text', (col) => col.notNull().references('pikku_roles.name').onDelete('cascade'))
42
+ .addColumn('scope', 'text', (col) => col.notNull().references('pikku_scopes.name').onDelete('cascade'))
43
+ .addPrimaryKeyConstraint('pikku_role_scopes_pk', ['role', 'scope'])
44
+ .execute();
45
+ await this.db.schema
46
+ .createTable('pikku_user_role')
47
+ .ifNotExists()
48
+ .addColumn('user_id', 'text', (col) => col.notNull().references('user.id').onDelete('cascade'))
49
+ .addColumn('role', 'text', (col) => col.notNull().references('pikku_roles.name').onDelete('cascade'))
50
+ .addColumn('granted_by', 'text')
51
+ .addColumn('granted_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
52
+ .addPrimaryKeyConstraint('pikku_user_role_pk', ['user_id', 'role'])
53
+ .execute();
54
+ await this.db.schema
55
+ .createTable('pikku_user_scope')
56
+ .ifNotExists()
57
+ .addColumn('user_id', 'text', (col) => col.notNull().references('user.id').onDelete('cascade'))
58
+ .addColumn('scope', 'text', (col) => col.notNull().references('pikku_scopes.name').onDelete('cascade'))
59
+ .addColumn('granted_by', 'text')
60
+ .addColumn('granted_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
61
+ .addPrimaryKeyConstraint('pikku_user_scope_pk', ['user_id', 'scope'])
62
+ .execute();
63
+ await this.db.schema
64
+ .createIndex('pikku_role_scopes_scope_idx')
65
+ .ifNotExists()
66
+ .on('pikku_role_scopes')
67
+ .column('scope')
68
+ .execute();
69
+ await this.db.schema
70
+ .createIndex('pikku_user_role_role_idx')
71
+ .ifNotExists()
72
+ .on('pikku_user_role')
73
+ .column('role')
74
+ .execute();
75
+ this.initialized = true;
76
+ }
77
+ /**
78
+ * Registers the declared scope set.
79
+ *
80
+ * Additive: rows are upserted and anything no longer declared is *marked*
81
+ * (`declared = false`), never deleted. Marking is non-destructive, so a
82
+ * rename, a rollback, or a rolling deploy where an older replica is still
83
+ * serving cannot silently strip a grant. `pruneScopes` is the deliberate
84
+ * removal path.
85
+ */
86
+ async syncScopes(scopes) {
87
+ await this.db.transaction().execute(async (trx) => {
88
+ if (scopes.length > 0) {
89
+ await trx
90
+ .insertInto('pikkuScopes')
91
+ .values(scopes.map((scope) => ({
92
+ name: scope.id,
93
+ description: scope.description ?? null,
94
+ declared: true,
95
+ })))
96
+ .onConflict((oc) => oc.column('name').doUpdateSet((eb) => ({
97
+ description: eb.ref('excluded.description'),
98
+ declared: true,
99
+ })))
100
+ .execute();
101
+ }
102
+ const markStale = trx.updateTable('pikkuScopes').set({ declared: false });
103
+ await (scopes.length > 0
104
+ ? markStale.where('name', 'not in', scopes.map((s) => s.id))
105
+ : markStale).execute();
106
+ });
107
+ }
108
+ async resolveScopes(userId) {
109
+ const roleScopes = this.db
110
+ .selectFrom('pikkuUserRole')
111
+ .innerJoin('pikkuRoleScopes', 'pikkuRoleScopes.role', 'pikkuUserRole.role')
112
+ .select('pikkuRoleScopes.scope')
113
+ .where('pikkuUserRole.userId', '=', userId);
114
+ const directScopes = this.db
115
+ .selectFrom('pikkuUserScope')
116
+ .select('pikkuUserScope.scope')
117
+ .where('pikkuUserScope.userId', '=', userId);
118
+ const rows = await roleScopes.union(directScopes).execute();
119
+ return rows.map((row) => row.scope);
120
+ }
121
+ async listScopes() {
122
+ const rows = await this.db
123
+ .selectFrom('pikkuScopes')
124
+ .select(['name', 'description', 'declared'])
125
+ .execute();
126
+ return rows.map((row) => ({
127
+ id: row.name,
128
+ description: row.description ?? undefined,
129
+ declared: !!row.declared,
130
+ }));
131
+ }
132
+ async createRole(role) {
133
+ await this.db.transaction().execute(async (trx) => {
134
+ await trx
135
+ .insertInto('pikkuRoles')
136
+ .values({ name: role.name, description: role.description ?? null })
137
+ .execute();
138
+ if (role.scopes.length > 0) {
139
+ await trx
140
+ .insertInto('pikkuRoleScopes')
141
+ .values(role.scopes.map((scope) => ({ role: role.name, scope })))
142
+ .execute();
143
+ }
144
+ });
145
+ }
146
+ async deleteRole(name) {
147
+ await this.db.deleteFrom('pikkuRoles').where('name', '=', name).execute();
148
+ }
149
+ async setRoleScopes(name, scopes) {
150
+ await this.db.transaction().execute(async (trx) => {
151
+ await trx.deleteFrom('pikkuRoleScopes').where('role', '=', name).execute();
152
+ if (scopes.length > 0) {
153
+ await trx
154
+ .insertInto('pikkuRoleScopes')
155
+ .values(scopes.map((scope) => ({ role: name, scope })))
156
+ .execute();
157
+ }
158
+ });
159
+ }
160
+ async listRoles() {
161
+ const roles = await this.db
162
+ .selectFrom('pikkuRoles')
163
+ .select(['name', 'description'])
164
+ .execute();
165
+ const scopeRows = await this.db
166
+ .selectFrom('pikkuRoleScopes')
167
+ .select(['role', 'scope'])
168
+ .execute();
169
+ const byRole = new Map();
170
+ for (const row of scopeRows) {
171
+ const existing = byRole.get(row.role);
172
+ if (existing) {
173
+ existing.push(row.scope);
174
+ }
175
+ else {
176
+ byRole.set(row.role, [row.scope]);
177
+ }
178
+ }
179
+ return roles.map((role) => ({
180
+ name: role.name,
181
+ description: role.description ?? undefined,
182
+ scopes: byRole.get(role.name) ?? [],
183
+ }));
184
+ }
185
+ async addUserToRole(userId, role, grantedBy) {
186
+ await this.db
187
+ .insertInto('pikkuUserRole')
188
+ .values({ userId, role, grantedBy: grantedBy ?? null })
189
+ .onConflict((oc) => oc.columns(['userId', 'role']).doNothing())
190
+ .execute();
191
+ }
192
+ async removeUserFromRole(userId, role) {
193
+ await this.db
194
+ .deleteFrom('pikkuUserRole')
195
+ .where('userId', '=', userId)
196
+ .where('role', '=', role)
197
+ .execute();
198
+ }
199
+ async listUserRoles(userId) {
200
+ const rows = await this.db
201
+ .selectFrom('pikkuUserRole')
202
+ .select('role')
203
+ .where('userId', '=', userId)
204
+ .execute();
205
+ return rows.map((row) => row.role);
206
+ }
207
+ async addScopeToUser(userId, scope, grantedBy) {
208
+ await this.db
209
+ .insertInto('pikkuUserScope')
210
+ .values({ userId, scope, grantedBy: grantedBy ?? null })
211
+ .onConflict((oc) => oc.columns(['userId', 'scope']).doNothing())
212
+ .execute();
213
+ }
214
+ async removeScopeFromUser(userId, scope) {
215
+ await this.db
216
+ .deleteFrom('pikkuUserScope')
217
+ .where('userId', '=', userId)
218
+ .where('scope', '=', scope)
219
+ .execute();
220
+ }
221
+ async listUserScopes(userId) {
222
+ const rows = await this.db
223
+ .selectFrom('pikkuUserScope')
224
+ .select('scope')
225
+ .where('userId', '=', userId)
226
+ .execute();
227
+ return rows.map((row) => row.scope);
228
+ }
229
+ /**
230
+ * Scopes marked undeclared by the last sync, with the roles that would lose
231
+ * them. Powers `pikku scopes audit`.
232
+ */
233
+ async findStaleScopes() {
234
+ const stale = await this.db
235
+ .selectFrom('pikkuScopes')
236
+ .leftJoin('pikkuRoleScopes', 'pikkuRoleScopes.scope', 'pikkuScopes.name')
237
+ .select(['pikkuScopes.name as scope', 'pikkuRoleScopes.role as role'])
238
+ .where('pikkuScopes.declared', '=', false)
239
+ .execute();
240
+ const byScope = new Map();
241
+ for (const row of stale) {
242
+ const roles = byScope.get(row.scope) ?? [];
243
+ if (row.role) {
244
+ roles.push(row.role);
245
+ }
246
+ byScope.set(row.scope, roles);
247
+ }
248
+ return [...byScope.entries()].map(([scope, roles]) => ({ scope, roles }));
249
+ }
250
+ /**
251
+ * Removes undeclared scopes, cascading them out of every role that holds
252
+ * them. This revokes access, so it is never run implicitly.
253
+ */
254
+ async pruneScopes() {
255
+ const stale = await this.findStaleScopes();
256
+ if (stale.length === 0) {
257
+ return [];
258
+ }
259
+ const names = stale.map((s) => s.scope);
260
+ const deleted = await this.db
261
+ .deleteFrom('pikkuScopes')
262
+ .where('name', 'in', names)
263
+ .where('declared', '=', false)
264
+ .returning('name')
265
+ .execute();
266
+ return deleted.map((r) => r.name);
267
+ }
268
+ }
@@ -164,7 +164,72 @@ export interface UserSessionsTable {
164
164
  createdAt: Generated<Date>;
165
165
  updatedAt: Generated<Date>;
166
166
  }
167
+ /**
168
+ * The declared scope set, synced from generated SCOPES.
169
+ *
170
+ * Additive: a scope removed from code leaves an inert row rather than
171
+ * cascading a grant away mid-deploy. `pikku scopes prune` removes them
172
+ * deliberately.
173
+ */
174
+ export interface PikkuScopesTable {
175
+ name: string;
176
+ description: string | null;
177
+ /**
178
+ * False once a scope is no longer declared in code. Marking is
179
+ * non-destructive — grants survive until `pikku scopes prune` removes them
180
+ * deliberately.
181
+ */
182
+ declared: Generated<boolean>;
183
+ }
184
+ /** A role: an admin-composed bag of scopes. */
185
+ export interface PikkuRolesTable {
186
+ name: string;
187
+ description: string | null;
188
+ createdAt: Generated<Date>;
189
+ }
190
+ export interface PikkuRoleScopesTable {
191
+ role: string;
192
+ scope: string;
193
+ }
194
+ export interface PikkuUserRoleTable {
195
+ userId: string;
196
+ role: string;
197
+ grantedBy: string | null;
198
+ grantedAt: Generated<Date>;
199
+ }
200
+ /** A scope granted directly to a user, outside of any role. */
201
+ export interface PikkuUserScopeTable {
202
+ userId: string;
203
+ scope: string;
204
+ grantedBy: string | null;
205
+ grantedAt: Generated<Date>;
206
+ }
207
+ export interface WebhookDeliveryTable {
208
+ deliveryId: string;
209
+ organizationId: string | null;
210
+ url: string;
211
+ event: string | null;
212
+ status: Generated<'pending' | 'delivered' | 'failed'>;
213
+ attempts: Generated<number>;
214
+ createdAt: Generated<Date>;
215
+ updatedAt: Generated<Date>;
216
+ deliveredAt: Date | null;
217
+ }
218
+ export interface WebhookDeliveryAttemptTable {
219
+ attemptId: string;
220
+ deliveryId: string;
221
+ attemptNumber: number;
222
+ statusCode: number | null;
223
+ responseBody: string | null;
224
+ error: string | null;
225
+ createdAt: Generated<Date>;
226
+ }
167
227
  export interface KyselyPikkuDB {
228
+ pikkuScopes: PikkuScopesTable;
229
+ pikkuRoles: PikkuRolesTable;
230
+ pikkuRoleScopes: PikkuRoleScopesTable;
231
+ pikkuUserRole: PikkuUserRoleTable;
232
+ pikkuUserScope: PikkuUserScopeTable;
168
233
  channels: ChannelsTable;
169
234
  channelSubscriptions: ChannelSubscriptionsTable;
170
235
  workflowRuns: WorkflowRunsTable;
@@ -183,4 +248,6 @@ export interface KyselyPikkuDB {
183
248
  credentials: CredentialsTable;
184
249
  credentialsAudit: CredentialsAuditTable;
185
250
  pikkuUserSessions: UserSessionsTable;
251
+ webhookDelivery: WebhookDeliveryTable;
252
+ webhookDeliveryAttempt: WebhookDeliveryAttemptTable;
186
253
  }
@@ -0,0 +1,27 @@
1
+ import { QueueWebhookService, type SendWebhookInput, type SendWebhookResult, type WebhookAttemptResult, type WebhookDeliveryRecord, type WebhookDeliveryWithAttempts } from '@pikku/core/services';
2
+ import type { QueueService } from '@pikku/core/queue';
3
+ import type { Kysely } from 'kysely';
4
+ import type { KyselyPikkuDB } from './kysely-tables.js';
5
+ /**
6
+ * Durable {@link QueueWebhookService}: still delivers through the
7
+ * `pikku-outgoing-webhooks` queue, but records a `webhook_delivery` row per
8
+ * `send()` and one `webhook_delivery_attempt` row per try. Register it as the
9
+ * `webhookService` singleton; the queue worker persists each attempt through
10
+ * {@link recordAttempt}, overriding the base's throwing default.
11
+ */
12
+ export declare class KyselyWebhookService extends QueueWebhookService {
13
+ private db;
14
+ private initialized;
15
+ constructor(queueService: QueueService, db: Kysely<KyselyPikkuDB>);
16
+ init(): Promise<void>;
17
+ send(input: SendWebhookInput): Promise<SendWebhookResult>;
18
+ recordAttempt(deliveryId: string, { statusCode, responseBody, error, delivered }: WebhookAttemptResult): Promise<void>;
19
+ /** List deliveries, most recent first — for the console webhooks view. */
20
+ listDeliveries(opts?: {
21
+ organizationId?: string;
22
+ limit?: number;
23
+ }): Promise<WebhookDeliveryRecord[]>;
24
+ /** A single delivery with its full attempt history, or null if unknown. */
25
+ getDelivery(deliveryId: string): Promise<WebhookDeliveryWithAttempts | null>;
26
+ private createIndexSafe;
27
+ }