@pikku/kysely 0.12.18 → 0.13.1
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 +87 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +3 -0
- package/dist/src/kysely-audit-service.d.ts +19 -0
- package/dist/src/kysely-audit-service.js +55 -0
- package/dist/src/kysely-scope-service.d.ts +59 -0
- package/dist/src/kysely-scope-service.js +268 -0
- package/dist/src/kysely-tables.d.ts +67 -0
- package/dist/src/kysely-webhook-service.d.ts +27 -0
- package/dist/src/kysely-webhook-service.js +150 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/run-tests.sh +4 -4
- package/src/index.ts +3 -0
- package/src/kysely-audit-service.ts +62 -0
- package/src/kysely-scope-service.test.ts +416 -0
- package/src/kysely-scope-service.ts +350 -0
- package/src/kysely-tables.ts +74 -0
- package/src/kysely-webhook-service.test.ts +120 -0
- package/src/kysely-webhook-service.ts +200 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,90 @@
|
|
|
1
|
+
## 0.13.1
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- 13474a6: feat(scopes): grant scopes directly to a user, not only through roles
|
|
6
|
+
|
|
7
|
+
A scope can now be granted to a user directly, outside of any role.
|
|
8
|
+
`resolveScopes` returns the union of a user's role-derived scopes and their
|
|
9
|
+
direct grants, so a one-off capability no longer requires inventing a role.
|
|
10
|
+
- `@pikku/core`: `ScopeService` gains `addScopeToUser` / `removeScopeFromUser` /
|
|
11
|
+
`listUserScopes`.
|
|
12
|
+
- `@pikku/kysely`: a new `pikku_user_scope` table (FK into `pikku_scopes`, so the
|
|
13
|
+
database still refuses an undeclared grant; `ON DELETE CASCADE` from `user`,
|
|
14
|
+
so deleting a user takes their direct grants with it). `resolveScopes` unions
|
|
15
|
+
it with the role join.
|
|
16
|
+
- `@pikku/addon-console`: `scopeAddScopeToUser` / `scopeRemoveScopeFromUser`
|
|
17
|
+
(gated by `pikku:scopes:manage`), and `scopeListUserRoles` now also returns
|
|
18
|
+
`directScopes`.
|
|
19
|
+
- `@pikku/console`: a **Direct scopes** section in the user roles drawer to grant
|
|
20
|
+
and revoke scopes directly, showing them distinctly from the resolved union.
|
|
21
|
+
|
|
22
|
+
Also: the Scopes page now distinguishes a permission error (a console admin
|
|
23
|
+
without `pikku:scopes:read`) from an actual scope-service outage, instead of
|
|
24
|
+
showing "the scope service may be unavailable" for both.
|
|
25
|
+
|
|
26
|
+
- 13474a6: feat: KyselyScopeService — resolve and administer user scopes
|
|
27
|
+
|
|
28
|
+
Adds `KyselyScopeService`, backing the core `ScopeService` interface with four
|
|
29
|
+
self-created tables: `pikku_scopes`, `pikku_roles`, `pikku_role_scopes` and
|
|
30
|
+
`pikku_user_role`.
|
|
31
|
+
|
|
32
|
+
Scopes are declared in code and synced additively — a scope that is no longer
|
|
33
|
+
declared is marked, never deleted, so a rename or a rolling deploy cannot
|
|
34
|
+
silently revoke a grant. `pikku scopes prune` is the deliberate removal path.
|
|
35
|
+
Roles are data, composed by admins at runtime, and `pikku_role_scopes` has a
|
|
36
|
+
foreign key into `pikku_scopes`, so the database itself refuses to grant a
|
|
37
|
+
scope that was never declared.
|
|
38
|
+
|
|
39
|
+
- 13474a6: feat: ScopeService.listScopes
|
|
40
|
+
|
|
41
|
+
Exposes the scope vocabulary held in the store — everything a role can be
|
|
42
|
+
composed from — flagging any scope that is still present but no longer declared
|
|
43
|
+
in code (inert, and awaiting `pikku scopes prune`).
|
|
44
|
+
|
|
45
|
+
- 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.
|
|
46
|
+
- Updated dependencies [7ab5287]
|
|
47
|
+
- Updated dependencies [e86bc17]
|
|
48
|
+
- Updated dependencies [a9b96a0]
|
|
49
|
+
- Updated dependencies [3f7fc54]
|
|
50
|
+
- Updated dependencies [c478794]
|
|
51
|
+
- Updated dependencies [3f04ae4]
|
|
52
|
+
- Updated dependencies [90d9f04]
|
|
53
|
+
- Updated dependencies [cb079cc]
|
|
54
|
+
- Updated dependencies [cb079cc]
|
|
55
|
+
- Updated dependencies [0a7db82]
|
|
56
|
+
- Updated dependencies [981c4db]
|
|
57
|
+
- Updated dependencies [13474a6]
|
|
58
|
+
- Updated dependencies [5a2b0d5]
|
|
59
|
+
- Updated dependencies [13474a6]
|
|
60
|
+
- Updated dependencies [ee040dc]
|
|
61
|
+
- Updated dependencies [cb079cc]
|
|
62
|
+
- Updated dependencies [13474a6]
|
|
63
|
+
- Updated dependencies [9f0d0eb]
|
|
64
|
+
- Updated dependencies [13474a6]
|
|
65
|
+
- Updated dependencies [70fa400]
|
|
66
|
+
- Updated dependencies [7b2ea23]
|
|
67
|
+
- Updated dependencies [1dc77d5]
|
|
68
|
+
- Updated dependencies [416606c]
|
|
69
|
+
- Updated dependencies [d2a6eea]
|
|
70
|
+
- Updated dependencies [30e62ee]
|
|
71
|
+
- @pikku/core@0.12.64
|
|
72
|
+
|
|
73
|
+
## 0.13.0
|
|
74
|
+
|
|
75
|
+
### Minor Changes
|
|
76
|
+
|
|
77
|
+
- 241e6cf: Add `KyselyAuditService` — a durable `AuditService` that persists AuditEvents to an `audit` table via Kysely (the companion sink to `createAuditedKysely`). Its column mapping matches Fabric's platform audit-queue consumer, so a locally-run project and a deployed stage produce identical rows. Use it as the local/dev audit sink so audit events persist and are queryable without the platform queue.
|
|
78
|
+
|
|
79
|
+
### Patch Changes
|
|
80
|
+
|
|
81
|
+
- 41ce2cb: Upgrade to TypeScript 6 and raise the minimum Node.js version to 22.
|
|
82
|
+
|
|
83
|
+
All packages now build against `typescript@^6.0.3` and declare `engines.node >= 22`. Internal tooling (`ts-json-schema-generator`, `zod-to-ts`) was bumped to TypeScript 6-compatible releases.
|
|
84
|
+
|
|
85
|
+
- Updated dependencies [41ce2cb]
|
|
86
|
+
- @pikku/core@0.12.44
|
|
87
|
+
|
|
1
88
|
## 0.12.18
|
|
2
89
|
|
|
3
90
|
### Patch Changes
|
package/dist/src/index.d.ts
CHANGED
|
@@ -12,7 +12,10 @@ 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';
|
|
18
|
+
export { KyselyAuditService } from './kysely-audit-service.js';
|
|
16
19
|
export { SerializePlugin, SqliteSerializePlugin, BaseSerializePlugin, defaultSerializer, defaultDeserializer, maybeJson, skipTransform, dateRegex, type Serializer, type Deserializer, type SerializePluginOptions, } from './serialize-plugin.js';
|
|
17
20
|
export type { KyselyPikkuDB } from './kysely-tables.js';
|
|
18
21
|
export type { WorkflowRunService } from '@pikku/core/workflow';
|
package/dist/src/index.js
CHANGED
|
@@ -10,5 +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';
|
|
16
|
+
export { KyselyAuditService } from './kysely-audit-service.js';
|
|
14
17
|
export { SerializePlugin, SqliteSerializePlugin, BaseSerializePlugin, defaultSerializer, defaultDeserializer, maybeJson, skipTransform, dateRegex, } from './serialize-plugin.js';
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AuditEvent, AuditEventBatch, AuditService } from '@pikku/core';
|
|
2
|
+
import type { Kysely } from 'kysely';
|
|
3
|
+
/**
|
|
4
|
+
* Durable {@link AuditService} that persists AuditEvents to an `audit` table
|
|
5
|
+
* via Kysely — the companion sink to {@link createAuditedKysely}.
|
|
6
|
+
*
|
|
7
|
+
* The column mapping matches a platform audit-queue consumer, so a locally-run
|
|
8
|
+
* project and a deployed stage write identical rows (the read side relies only
|
|
9
|
+
* on `type`, `occurred_at`, `actor_user_id`, and the `data` JSON that holds
|
|
10
|
+
* `metadata`). The `audit` table is an optional per-project migration (not in
|
|
11
|
+
* the generated schema), so rows are inserted untyped; all columns are TEXT on
|
|
12
|
+
* every engine and ON CONFLICT DO NOTHING keeps writes idempotent on retries.
|
|
13
|
+
*/
|
|
14
|
+
export declare class KyselyAuditService implements AuditService {
|
|
15
|
+
private db;
|
|
16
|
+
constructor(db: Kysely<any>);
|
|
17
|
+
audit(event: AuditEvent): Promise<void>;
|
|
18
|
+
write(batch: AuditEventBatch): Promise<void>;
|
|
19
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const jsonOrNull = (v) => v != null ? JSON.stringify(v) : null;
|
|
2
|
+
// No global `crypto` is guaranteed across every runtime, and audit_id is the
|
|
3
|
+
// PK — any collision is dropped by ON CONFLICT DO NOTHING.
|
|
4
|
+
const fallbackId = () => `evt_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
|
|
5
|
+
/**
|
|
6
|
+
* Durable {@link AuditService} that persists AuditEvents to an `audit` table
|
|
7
|
+
* via Kysely — the companion sink to {@link createAuditedKysely}.
|
|
8
|
+
*
|
|
9
|
+
* The column mapping matches a platform audit-queue consumer, so a locally-run
|
|
10
|
+
* project and a deployed stage write identical rows (the read side relies only
|
|
11
|
+
* on `type`, `occurred_at`, `actor_user_id`, and the `data` JSON that holds
|
|
12
|
+
* `metadata`). The `audit` table is an optional per-project migration (not in
|
|
13
|
+
* the generated schema), so rows are inserted untyped; all columns are TEXT on
|
|
14
|
+
* every engine and ON CONFLICT DO NOTHING keeps writes idempotent on retries.
|
|
15
|
+
*/
|
|
16
|
+
export class KyselyAuditService {
|
|
17
|
+
db;
|
|
18
|
+
constructor(db) {
|
|
19
|
+
this.db = db;
|
|
20
|
+
}
|
|
21
|
+
async audit(event) {
|
|
22
|
+
await this.write([event]);
|
|
23
|
+
}
|
|
24
|
+
async write(batch) {
|
|
25
|
+
if (!batch.length)
|
|
26
|
+
return;
|
|
27
|
+
const rows = batch.map((e) => {
|
|
28
|
+
const metadata = e.metadata;
|
|
29
|
+
return {
|
|
30
|
+
audit_id: e.eventId ?? e.queryId ?? fallbackId(),
|
|
31
|
+
occurred_at: e.occurredAt ?? new Date().toISOString(),
|
|
32
|
+
type: e.type ?? 'unknown',
|
|
33
|
+
source: e.source ?? 'auto',
|
|
34
|
+
outcome: e.outcome ?? null,
|
|
35
|
+
function_id: e.functionId ?? null,
|
|
36
|
+
wire_type: e.wireType ?? null,
|
|
37
|
+
trace_id: e.traceId ?? null,
|
|
38
|
+
transaction_id: e.transactionId ?? null,
|
|
39
|
+
query_id: e.queryId ?? null,
|
|
40
|
+
actor_user_id: e.actor?.userId ?? null,
|
|
41
|
+
actor_org_id: e.actor?.orgId ?? null,
|
|
42
|
+
tables: jsonOrNull(metadata?.tables),
|
|
43
|
+
changed_cols: jsonOrNull(metadata?.changedColumns),
|
|
44
|
+
event: e.eventId ?? null,
|
|
45
|
+
old: null,
|
|
46
|
+
data: jsonOrNull(e.metadata),
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
await this.db
|
|
50
|
+
.insertInto('audit')
|
|
51
|
+
.values(rows)
|
|
52
|
+
.onConflict((oc) => oc.doNothing())
|
|
53
|
+
.execute();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -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
|
+
}
|