@pikku/cli 0.12.26 → 0.12.27
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/console-app/assets/index-BERGDBO9.js +228 -0
- package/console-app/assets/{index-C52h1B_L.css → index-CQ29NRyR.css} +1 -1
- package/console-app/index.html +2 -2
- package/dist/.pikku/agent/pikku-agent-types.gen.d.ts +1 -1
- package/dist/.pikku/channel/pikku-channel-types.gen.d.ts +1 -1
- package/dist/.pikku/channel/pikku-channel-types.gen.js +1 -1
- package/dist/.pikku/cli/pikku-cli-channel.js +1 -1
- package/dist/.pikku/cli/pikku-cli-types.gen.d.ts +1 -1
- package/dist/.pikku/cli/pikku-cli-types.gen.js +1 -1
- package/dist/.pikku/cli/pikku-cli-wirings-meta.gen.js +1 -1
- package/dist/.pikku/cli/pikku-cli-wirings.gen.d.ts +1 -1
- package/dist/.pikku/cli/pikku-cli-wirings.gen.js +1 -1
- package/dist/.pikku/cli/pikku-cli.gen.d.ts +1 -1
- package/dist/.pikku/cli/pikku-cli.gen.js +1 -1
- package/dist/.pikku/console/pikku-node-types.gen.d.ts +1 -1
- package/dist/.pikku/function/pikku-function-types.gen.d.ts +1 -1
- package/dist/.pikku/function/pikku-function-types.gen.js +1 -1
- package/dist/.pikku/function/pikku-functions-meta.gen.js +1 -1
- package/dist/.pikku/function/pikku-functions-meta.gen.json +72 -72
- package/dist/.pikku/function/pikku-functions.gen.js +1 -1
- package/dist/.pikku/http/pikku-http-types.gen.d.ts +1 -1
- package/dist/.pikku/http/pikku-http-types.gen.js +1 -1
- package/dist/.pikku/http/pikku-http-wirings-meta.gen.js +1 -1
- package/dist/.pikku/http/pikku-http-wirings.gen.d.ts +1 -1
- package/dist/.pikku/http/pikku-http-wirings.gen.js +1 -1
- package/dist/.pikku/mcp/pikku-mcp-types.gen.d.ts +1 -1
- package/dist/.pikku/mcp/pikku-mcp-types.gen.js +1 -1
- package/dist/.pikku/pikku-bootstrap.gen.d.ts +1 -1
- package/dist/.pikku/pikku-bootstrap.gen.js +1 -1
- package/dist/.pikku/pikku-meta-service.gen.d.ts +1 -1
- package/dist/.pikku/pikku-meta-service.gen.js +1 -1
- package/dist/.pikku/pikku-services.gen.d.ts +1 -1
- package/dist/.pikku/pikku-types.gen.d.ts +1 -1
- package/dist/.pikku/pikku-types.gen.js +1 -1
- package/dist/.pikku/queue/pikku-queue-types.gen.d.ts +1 -1
- package/dist/.pikku/queue/pikku-queue-types.gen.js +1 -1
- package/dist/.pikku/queue/pikku-queue-workers-wirings-meta.gen.js +1 -1
- package/dist/.pikku/queue/pikku-queue-workers-wirings.gen.d.ts +1 -1
- package/dist/.pikku/queue/pikku-queue-workers-wirings.gen.js +1 -1
- package/dist/.pikku/rpc/pikku-rpc-wirings-meta.internal.gen.js +1 -1
- package/dist/.pikku/rpc/pikku-rpc-wirings-meta.internal.gen.json +3 -3
- package/dist/.pikku/scheduler/pikku-scheduler-types.gen.d.ts +1 -1
- package/dist/.pikku/scheduler/pikku-scheduler-types.gen.js +1 -1
- package/dist/.pikku/schemas/register.gen.js +7 -7
- package/dist/.pikku/secrets/pikku-secret-types.gen.d.ts +1 -1
- package/dist/.pikku/secrets/pikku-secret-types.gen.js +1 -1
- package/dist/.pikku/secrets/pikku-secrets.gen.d.ts +1 -1
- package/dist/.pikku/secrets/pikku-secrets.gen.js +1 -1
- package/dist/.pikku/trigger/pikku-trigger-types.gen.d.ts +1 -1
- package/dist/.pikku/trigger/pikku-trigger-types.gen.js +1 -1
- package/dist/.pikku/variables/pikku-variable-types.gen.d.ts +1 -1
- package/dist/.pikku/variables/pikku-variable-types.gen.js +1 -1
- package/dist/.pikku/variables/pikku-variables.gen.d.ts +1 -1
- package/dist/.pikku/variables/pikku-variables.gen.js +1 -1
- package/dist/.pikku/workflow/pikku-workflow-types.gen.d.ts +1 -1
- package/dist/.pikku/workflow/pikku-workflow-types.gen.js +1 -1
- package/dist/.pikku/workflow/pikku-workflow-wirings-meta.gen.js +1 -1
- package/dist/.pikku/workflow/pikku-workflow-wirings.gen.js +1 -1
- package/dist/bin/pikku-bin.mjs +2 -2
- package/dist/src/fabric/functions/validate-core.js +6 -6
- package/dist/src/functions/db/local-db.d.ts +1 -1
- package/dist/src/functions/db/local-db.js +4 -4
- package/dist/src/functions/validate/workspace-validate.js +4 -1
- package/dist/src/scaffold/rpc-remote.gen.js +1 -1
- package/package.json +3 -3
- package/skills/pikku-middleware/SKILL.md +283 -0
- package/skills/pikku-permissions/SKILL.md +165 -0
- package/skills/pikku-security/SKILL.md +38 -177
- package/skills/pikku-tag-middleware/SKILL.md +13 -0
- package/console-app/assets/index-Ba9K10XZ.js +0 -232
package/dist/bin/pikku-bin.mjs
CHANGED
|
@@ -11,8 +11,8 @@ async function checkForUpdate() {
|
|
|
11
11
|
})
|
|
12
12
|
if (!res.ok) return
|
|
13
13
|
const { version: latest } = await res.json()
|
|
14
|
-
if (latest !== '0.12.
|
|
15
|
-
process.stderr.write(`\n Update available 0.12.
|
|
14
|
+
if (latest !== '0.12.27') {
|
|
15
|
+
process.stderr.write(`\n Update available 0.12.27 → ${latest}\n brew upgrade pikku or npm install -g @pikku/cli\n\n`)
|
|
16
16
|
}
|
|
17
17
|
} catch {}
|
|
18
18
|
}
|
|
@@ -90,10 +90,10 @@ export async function runFabricValidate(startDir = process.cwd()) {
|
|
|
90
90
|
e('missing-kysely-sqlite', 'services.ts imports @pikku/kysely-sqlite but it is not in root package.json', rootPkgPath, 'Add "@pikku/kysely-sqlite": "file:./vendor/pikku-kysely-sqlite.tgz" to dependencies');
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
|
-
// db/
|
|
94
|
-
const migrationsDir = join(fnDir, 'db', '
|
|
93
|
+
// db/sqlite/ — presence, numbering and SQL dialect
|
|
94
|
+
const migrationsDir = join(fnDir, 'db', 'sqlite');
|
|
95
95
|
if (!existsSync(migrationsDir)) {
|
|
96
|
-
e('migrations-dir-missing', 'packages/functions/db/
|
|
96
|
+
e('migrations-dir-missing', 'packages/functions/db/sqlite/ not found', migrationsDir, 'Create db/sqlite/ and add numbered .sql files (e.g. 0001-init.sql) using SQLite-compatible syntax');
|
|
97
97
|
}
|
|
98
98
|
else {
|
|
99
99
|
try {
|
|
@@ -125,10 +125,10 @@ export async function runFabricValidate(startDir = process.cwd()) {
|
|
|
125
125
|
// readdir failure — skip
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
|
-
// db/seed.sql
|
|
129
|
-
const seedPath = join(fnDir, 'db', 'seed.sql');
|
|
128
|
+
// db/sqlite-seed.sql
|
|
129
|
+
const seedPath = join(fnDir, 'db', 'sqlite-seed.sql');
|
|
130
130
|
if (!existsSync(seedPath)) {
|
|
131
|
-
e('seed-sql-missing', 'packages/functions/db/seed.sql not found', seedPath, 'Create db/seed.sql with idempotent INSERT OR IGNORE statements for demo/test data');
|
|
131
|
+
e('seed-sql-missing', 'packages/functions/db/sqlite-seed.sql not found', seedPath, 'Create db/sqlite-seed.sql with idempotent INSERT OR IGNORE statements for demo/test data');
|
|
132
132
|
}
|
|
133
133
|
}
|
|
134
134
|
const appsDir = join(root, 'apps');
|
|
@@ -6,6 +6,7 @@ import { type SeedResult } from './sqlite/seed.js';
|
|
|
6
6
|
import type { UserConfigShape } from '../commands/db-shared.js';
|
|
7
7
|
interface ResolvedDbBase {
|
|
8
8
|
migrationsDir: string;
|
|
9
|
+
seedFile: string;
|
|
9
10
|
schemaFile: string;
|
|
10
11
|
coercionFile: string;
|
|
11
12
|
manifestFile: string;
|
|
@@ -16,7 +17,6 @@ export interface ResolvedSqliteDb extends ResolvedDbBase {
|
|
|
16
17
|
dialect: 'sqlite';
|
|
17
18
|
dbFile: string;
|
|
18
19
|
runtimeDir: string;
|
|
19
|
-
seedFile: string;
|
|
20
20
|
}
|
|
21
21
|
export interface ResolvedPostgresDb extends ResolvedDbBase {
|
|
22
22
|
dialect: 'postgres';
|
|
@@ -17,8 +17,9 @@ import { PostgresIntrospector } from './postgres/postgres-introspector.js';
|
|
|
17
17
|
* Returns null when neither sqliteDb nor postgresUrl is configured.
|
|
18
18
|
*/
|
|
19
19
|
export function resolveDb(userConfig, rootDir, outDir, runtimeDir) {
|
|
20
|
-
const base = (sub) => ({
|
|
20
|
+
const base = (sub, seedFileName) => ({
|
|
21
21
|
migrationsDir: resolveAgainst(rootDir, sub),
|
|
22
|
+
seedFile: resolveAgainst(rootDir, seedFileName),
|
|
22
23
|
schemaFile: join(outDir, 'db', 'schema.d.ts'),
|
|
23
24
|
coercionFile: join(outDir, 'db', 'coercion.gen.ts'),
|
|
24
25
|
manifestFile: join(outDir, 'db', 'classification.gen.ts'),
|
|
@@ -32,7 +33,7 @@ export function resolveDb(userConfig, rootDir, outDir, runtimeDir) {
|
|
|
32
33
|
return {
|
|
33
34
|
dialect: 'postgres',
|
|
34
35
|
connectionString: userConfig.postgresUrl,
|
|
35
|
-
...base('db/postgres'),
|
|
36
|
+
...base('db/postgres', 'db/postgres-seed.sql'),
|
|
36
37
|
};
|
|
37
38
|
}
|
|
38
39
|
if (userConfig.sqliteDb) {
|
|
@@ -43,8 +44,7 @@ export function resolveDb(userConfig, rootDir, outDir, runtimeDir) {
|
|
|
43
44
|
dialect: 'sqlite',
|
|
44
45
|
dbFile: resolveAgainst(rootDir, userConfig.sqliteDb),
|
|
45
46
|
runtimeDir: resolvedRuntimeDir,
|
|
46
|
-
|
|
47
|
-
...base('db/sqlite'),
|
|
47
|
+
...base('db/sqlite', 'db/sqlite-seed.sql'),
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
50
|
return null;
|
|
@@ -142,10 +142,13 @@ export async function runWorkspaceValidate(startDir = process.cwd()) {
|
|
|
142
142
|
if (!servicesText) {
|
|
143
143
|
w('services-missing', 'packages/functions/src/services.ts not found', servicesPath, 'Create services.ts and export your service factory for the workspace');
|
|
144
144
|
}
|
|
145
|
-
const migrationsDir = join(fnDir, 'db', 'migrations');
|
|
146
145
|
const authEnabled = await hasAuthSessionMiddleware(fnDir);
|
|
147
146
|
const configText = await readTextSafe(join(fnDir, 'src', 'config.ts'));
|
|
148
147
|
const hasConfiguredDevDb = /sqliteDb/.test(configText ?? '');
|
|
148
|
+
const hasPostgresUrl = /postgresUrl/.test(configText ?? '');
|
|
149
|
+
const migrationsDir = hasPostgresUrl
|
|
150
|
+
? join(fnDir, 'db', 'postgres')
|
|
151
|
+
: join(fnDir, 'db', 'sqlite');
|
|
149
152
|
let createsAppUser = false;
|
|
150
153
|
let createsAuthVerificationToken = false;
|
|
151
154
|
if (existsSync(migrationsDir)) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/cli",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.27",
|
|
4
4
|
"author": "yasser.fadl@gmail.com",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"imports": {
|
|
@@ -26,11 +26,11 @@
|
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@openapi-contrib/json-schema-to-openapi-schema": "^4.3.1",
|
|
29
|
-
"@pikku/core": "^0.12.
|
|
29
|
+
"@pikku/core": "^0.12.26",
|
|
30
30
|
"@pikku/deploy-cloudflare": "^0.12.3",
|
|
31
31
|
"@pikku/fetch": "^0.12.2",
|
|
32
32
|
"@pikku/inspector": "^0.12.13",
|
|
33
|
-
"@pikku/kysely": "^0.12.
|
|
33
|
+
"@pikku/kysely": "^0.12.13",
|
|
34
34
|
"@pikku/kysely-node-sqlite": "^0.12.1",
|
|
35
35
|
"@pikku/node-http-server": "^0.12.1",
|
|
36
36
|
"@pikku/openapi-parser": "^0.12.10",
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pikku-middleware
|
|
3
|
+
description: 'Use when adding any middleware to a Pikku app — global HTTP middleware, tag-scoped middleware (including service-to-service bearer auth), per-route middleware, session-setting middleware, or understanding middleware execution order and priority.
|
|
4
|
+
TRIGGER when: user wants middleware on some or all routes, machine-to-machine auth, tag-scoped cross-cutting concerns, global interceptors, or middleware priority/order questions.
|
|
5
|
+
DO NOT TRIGGER when: user asks about permissions/authorization checks (use pikku-permissions), auth strategies like authBearer/authCookie (use pikku-security), or deployment.'
|
|
6
|
+
installGroups: [core]
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Pikku Middleware
|
|
10
|
+
|
|
11
|
+
## Agent Operating Procedure
|
|
12
|
+
|
|
13
|
+
1. Discover before editing. Run `pikku info middleware --verbose` and `pikku info tags --json` to understand the existing middleware and tag landscape.
|
|
14
|
+
2. Identify the source files that own the behavior — wirings files, not generated output.
|
|
15
|
+
3. Register middleware at module load time — in a `wirings/*.ts` file, never inside a function body.
|
|
16
|
+
4. Validate: run `pikku all` after adding or changing middleware; run `pikku tsc` to confirm type safety.
|
|
17
|
+
|
|
18
|
+
## The `pikkuMiddleware` Factory
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
import { pikkuMiddleware } from '#pikku'
|
|
22
|
+
|
|
23
|
+
// Simple: just a function
|
|
24
|
+
const myMiddleware = pikkuMiddleware(async (services, wire, next) => {
|
|
25
|
+
// runs before the function
|
|
26
|
+
await next()
|
|
27
|
+
// runs after the function (optional)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// With metadata (name + priority)
|
|
31
|
+
const telemetryMiddleware = pikkuMiddleware({
|
|
32
|
+
name: 'my-telemetry',
|
|
33
|
+
priority: 'highest',
|
|
34
|
+
func: async (services, wire, next) => {
|
|
35
|
+
const start = performance.now()
|
|
36
|
+
try {
|
|
37
|
+
await next()
|
|
38
|
+
} finally {
|
|
39
|
+
services.logger.info({ duration: Math.round(performance.now() - start) })
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The `wire` object gives you:
|
|
46
|
+
- `wire.http` — inbound HTTP context (headers, URL, cookies)
|
|
47
|
+
- `wire.setSession(session)` — set the session for this request
|
|
48
|
+
- `wire.getSession()` — read the current session
|
|
49
|
+
- `wire.session` — the session set so far (may be undefined)
|
|
50
|
+
|
|
51
|
+
Throw a typed error to abort: `UnauthorizedError`, `ForbiddenError`, etc. from `@pikku/core/errors`.
|
|
52
|
+
|
|
53
|
+
## Scoping: Five Levels
|
|
54
|
+
|
|
55
|
+
From broadest to narrowest:
|
|
56
|
+
|
|
57
|
+
```typescript
|
|
58
|
+
// 1. Wire-agnostic global: all wire types (HTTP, Queue, Channel, Trigger, Workflow, ...)
|
|
59
|
+
addGlobalMiddleware([telemetryOuter()])
|
|
60
|
+
|
|
61
|
+
// 2. HTTP global: all HTTP routes
|
|
62
|
+
addHTTPMiddleware('*', [cors(), authBearer()])
|
|
63
|
+
|
|
64
|
+
// 3. Prefix-based: URL pattern
|
|
65
|
+
addHTTPMiddleware('/admin/*', [auditLog])
|
|
66
|
+
|
|
67
|
+
// 4. Tag-based: any wiring with matching tag
|
|
68
|
+
addTagMiddleware('machine-agent', [bearerAuth]) // tag on function or wire
|
|
69
|
+
|
|
70
|
+
// 5. Inline: per-wiring
|
|
71
|
+
wireHTTP({
|
|
72
|
+
route: '/books/:id',
|
|
73
|
+
func: getBook,
|
|
74
|
+
middleware: [cacheControl],
|
|
75
|
+
})
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Global Middleware (`addGlobalMiddleware`)
|
|
79
|
+
|
|
80
|
+
`addGlobalMiddleware` registers middleware that runs before everything else — across every wire type: HTTP, Queue, Channel, Trigger, Scheduler, Workflow, Agent, CLI, MCP. Use it for cross-cutting concerns like telemetry that must wrap every invocation regardless of transport.
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
import { addGlobalMiddleware } from '@pikku/core'
|
|
84
|
+
import { telemetryOuter, telemetryInner } from '@pikku/core/middleware'
|
|
85
|
+
|
|
86
|
+
// Outer telemetry: wraps the full call (highest priority)
|
|
87
|
+
addGlobalMiddleware([telemetryOuter({ environmentId: env.STAGE_ID })])
|
|
88
|
+
|
|
89
|
+
// Inner telemetry: closest to the function body (lowest priority)
|
|
90
|
+
addGlobalMiddleware([telemetryInner({ environmentId: env.STAGE_ID })])
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`telemetryOuter` ships with `priority: 'highest'` and `telemetryInner` with `priority: 'lowest'` — so even if both are added in the same call, priority sorting places outer first regardless of array order.
|
|
94
|
+
|
|
95
|
+
## HTTP & Prefix Middleware (`addHTTPMiddleware`)
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
import { addHTTPMiddleware } from '@pikku/core/http'
|
|
99
|
+
import { cors, authBearer } from '@pikku/core/middleware'
|
|
100
|
+
|
|
101
|
+
// All routes
|
|
102
|
+
addHTTPMiddleware('*', [cors({ origin: 'https://app.example.com', credentials: true })])
|
|
103
|
+
|
|
104
|
+
// Scoped to /api/* prefix
|
|
105
|
+
addHTTPMiddleware('/api/*', [rateLimit({ maxRequests: 100, windowMs: 60_000 })])
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Tag Middleware (`addTagMiddleware`)
|
|
109
|
+
|
|
110
|
+
Tag middleware fires for any wiring (function or wire object) that carries a matching tag. This is the canonical approach for service-to-service bearer auth, rate limiting a group, or any cross-cutting concern scoped to a subset of routes.
|
|
111
|
+
|
|
112
|
+
### Setting Tags
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
// On the function definition
|
|
116
|
+
export const myFunc = pikkuSessionlessFunc({
|
|
117
|
+
auth: false,
|
|
118
|
+
tags: ['machine-agent'],
|
|
119
|
+
func: async (services, input) => { ... },
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// On the wire object
|
|
123
|
+
wireHTTP({
|
|
124
|
+
route: '/internal/action',
|
|
125
|
+
method: 'post',
|
|
126
|
+
auth: false,
|
|
127
|
+
tags: ['internal'],
|
|
128
|
+
func: myFunc,
|
|
129
|
+
})
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Tags from the function definition and the wire object are merged — middleware from both tag sets runs.
|
|
133
|
+
|
|
134
|
+
### Registering Tag Middleware
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
import { addTagMiddleware } from '.pikku/pikku-types.gen.js'
|
|
138
|
+
|
|
139
|
+
addTagMiddleware('machine-agent', [machineAgentBearerAuth])
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Call at module load time — typically in the same `wirings/*.ts` file as the `wireHTTP` calls that use the tag.
|
|
143
|
+
|
|
144
|
+
## Middleware Execution Order
|
|
145
|
+
|
|
146
|
+
**Scope resolution order (broadest → narrowest):**
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
global → httpGroup/* → httpGroup/prefix → wiringTags → wiringMiddleware → funcTags → funcMiddleware → function body
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**Within each scope, sorted by priority:**
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
highest → high → medium (default) → low → lowest
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Set priority using the config-object form of `pikkuMiddleware`:
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
const earlyMiddleware = pikkuMiddleware({
|
|
162
|
+
name: 'early',
|
|
163
|
+
priority: 'highest', // 'highest' | 'high' | 'medium' | 'low' | 'lowest'
|
|
164
|
+
func: async (services, wire, next) => { ... },
|
|
165
|
+
})
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Within the same priority level, registration order is preserved. Priority is the primary sort key — use it when a middleware must run before or after others regardless of registration order (e.g. telemetry wrapping everything, session extraction before auth checks).
|
|
169
|
+
|
|
170
|
+
## Common Patterns
|
|
171
|
+
|
|
172
|
+
### Service-to-Service Bearer Auth
|
|
173
|
+
|
|
174
|
+
The canonical pattern for a server that exposes RPCs only to a trusted caller (e.g. an API calling a machine-agent):
|
|
175
|
+
|
|
176
|
+
**On the server (the service being called):**
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
// lib/host-token.ts
|
|
180
|
+
let _token: string | null = null
|
|
181
|
+
export const setToken = (t: string) => { _token = t }
|
|
182
|
+
export const getToken = () => _token
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
// wirings/http.wiring.ts
|
|
187
|
+
import { timingSafeEqual } from 'node:crypto'
|
|
188
|
+
import { addTagMiddleware, pikkuMiddleware } from '../../.pikku/pikku-types.gen.js'
|
|
189
|
+
import { UnauthorizedError } from '@pikku/core/errors'
|
|
190
|
+
import { getToken } from '../lib/host-token.js'
|
|
191
|
+
|
|
192
|
+
const bearerAuth = pikkuMiddleware(async (_services, { http }, next) => {
|
|
193
|
+
const authHeader = http?.request?.header?.('authorization') || http?.request?.header?.('Authorization')
|
|
194
|
+
const token = getToken()
|
|
195
|
+
const expected = token ? `Bearer ${token}` : null
|
|
196
|
+
if (
|
|
197
|
+
!expected ||
|
|
198
|
+
!authHeader ||
|
|
199
|
+
authHeader.length !== expected.length ||
|
|
200
|
+
!timingSafeEqual(Buffer.from(authHeader), Buffer.from(expected))
|
|
201
|
+
) {
|
|
202
|
+
throw new UnauthorizedError()
|
|
203
|
+
}
|
|
204
|
+
return next()
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
addTagMiddleware('machine-agent', [bearerAuth])
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
```typescript
|
|
211
|
+
// functions/my.function.ts
|
|
212
|
+
export const myFunc = pikkuSessionlessFunc({
|
|
213
|
+
expose: true,
|
|
214
|
+
auth: false,
|
|
215
|
+
tags: ['machine-agent'],
|
|
216
|
+
func: async (services, input) => { ... },
|
|
217
|
+
})
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
**On the client (the caller):**
|
|
221
|
+
|
|
222
|
+
Use the generated `RPCInvoke` type from `.pikku/rpc/pikku-rpc-wirings-map.gen.d.ts` — never hand-write the input/output types:
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
import type { RPCInvoke } from '../../backends/my-service/.pikku/rpc/pikku-rpc-wirings-map.gen.d.js'
|
|
226
|
+
|
|
227
|
+
export function getServiceRPC(baseUrl: string, token: string): RPCInvoke {
|
|
228
|
+
return async (name: string, data?: unknown) => {
|
|
229
|
+
const res = await fetch(`${baseUrl}/rpc/${String(name)}`, {
|
|
230
|
+
method: 'POST',
|
|
231
|
+
headers: {
|
|
232
|
+
'Content-Type': 'application/json',
|
|
233
|
+
Authorization: `Bearer ${token}`,
|
|
234
|
+
},
|
|
235
|
+
body: JSON.stringify({ data: data ?? {} }),
|
|
236
|
+
})
|
|
237
|
+
if (!res.ok) {
|
|
238
|
+
const text = await res.text().catch(() => '')
|
|
239
|
+
throw new Error(`rpc ${String(name)} failed: ${res.status} ${text}`)
|
|
240
|
+
}
|
|
241
|
+
return res.json()
|
|
242
|
+
} as RPCInvoke
|
|
243
|
+
}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
### Session-Setting Middleware
|
|
247
|
+
|
|
248
|
+
```typescript
|
|
249
|
+
const apiKeyAuth = pikkuMiddleware(async ({ kysely }, { http, setSession, session }, next) => {
|
|
250
|
+
if (session) return next() // already authenticated
|
|
251
|
+
|
|
252
|
+
const header = http?.request?.header?.('x-api-key')
|
|
253
|
+
if (!header) return next()
|
|
254
|
+
|
|
255
|
+
const row = await kysely.selectFrom('apiKey').select('userId').where('key', '=', header).executeTakeFirst()
|
|
256
|
+
if (row) setSession?.({ userId: row.userId })
|
|
257
|
+
|
|
258
|
+
return next()
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
addTagMiddleware('api-key-auth', [apiKeyAuth])
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Functions tagged `'api-key-auth'` with `auth: true` reject requests without a valid key; those with `auth: false` can inspect the session but won't reject.
|
|
265
|
+
|
|
266
|
+
### Request Logging / Audit
|
|
267
|
+
|
|
268
|
+
```typescript
|
|
269
|
+
const auditLog = pikkuMiddleware(async ({ logger, db }, wire, next) => {
|
|
270
|
+
const start = Date.now()
|
|
271
|
+
await next()
|
|
272
|
+
await db.createAuditLog({ duration: Date.now() - start })
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
addHTTPMiddleware('/admin/*', [auditLog])
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
## After Changes
|
|
279
|
+
|
|
280
|
+
```bash
|
|
281
|
+
pikku all # regenerate metadata so new tags are picked up
|
|
282
|
+
pikku tsc # type-check
|
|
283
|
+
```
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pikku-permissions
|
|
3
|
+
description: 'Use when adding authorization checks to Pikku functions or routes — pikkuPermission, pikkuAuth, per-function permissions, pattern-based permissions, or understanding OR/AND permission logic.
|
|
4
|
+
TRIGGER when: user wants to restrict who can call a function, check resource ownership, add role-based access, or understand where permission checks belong.
|
|
5
|
+
DO NOT TRIGGER when: user asks about middleware or request interception (use pikku-middleware), authentication strategies (use pikku-security), or session management.'
|
|
6
|
+
installGroups: [core]
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Pikku Permissions
|
|
10
|
+
|
|
11
|
+
## The Rule
|
|
12
|
+
|
|
13
|
+
**ALWAYS put authorization checks in the `permissions` field of `pikkuFunc` or `pikkuSessionlessFunc` — NEVER inside the `func` body.**
|
|
14
|
+
|
|
15
|
+
This includes: org access checks, repo access checks, role checks, resource ownership, and any other authorization logic. The `permissions` field runs before `func`, is visible to the inspector, and is the only place Pikku enforces authorization.
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
// CORRECT
|
|
19
|
+
export const deleteBook = pikkuFunc({
|
|
20
|
+
func: async ({ db }, { bookId }) => {
|
|
21
|
+
await db.deleteBook(bookId)
|
|
22
|
+
},
|
|
23
|
+
permissions: {
|
|
24
|
+
owner: isBookOwner, // ← authorization here
|
|
25
|
+
},
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
// WRONG — permission check inside func body
|
|
29
|
+
export const deleteBook = pikkuFunc({
|
|
30
|
+
func: async ({ db }, { bookId }, { session }) => {
|
|
31
|
+
if (!session) throw new UnauthorizedError() // ← never do this
|
|
32
|
+
await db.deleteBook(bookId)
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Agent Operating Procedure
|
|
38
|
+
|
|
39
|
+
1. Discover before editing. Run `pikku info permissions --verbose` and `pikku info functions --verbose` to understand what permissions are already defined and applied.
|
|
40
|
+
2. Define permission checkers in a `src/permissions.ts` or domain-specific `src/lib/*-permissions.ts` file.
|
|
41
|
+
3. Apply them via the `permissions` field on the function, or via `addHTTPPermission` / `addPermission` for pattern/tag-based application.
|
|
42
|
+
4. Validate: run `pikku tsc` to confirm permission checker signatures are correct.
|
|
43
|
+
|
|
44
|
+
## Permission Factories
|
|
45
|
+
|
|
46
|
+
### `pikkuAuth(fn)` — Session-Only Checks
|
|
47
|
+
|
|
48
|
+
Use for checks that only need the session — no request data required.
|
|
49
|
+
|
|
50
|
+
```typescript
|
|
51
|
+
import { pikkuAuth } from '#pikku'
|
|
52
|
+
|
|
53
|
+
export const isAuthenticated = pikkuAuth(
|
|
54
|
+
async (_services, session) => !!session
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
export const isAdmin = pikkuAuth(
|
|
58
|
+
async (_services, session) => session?.role === 'admin'
|
|
59
|
+
)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### `pikkuPermission(fn)` — Data-Aware Checks
|
|
63
|
+
|
|
64
|
+
Use when authorization depends on the actual request data (e.g., resource ownership).
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
import { pikkuPermission } from '#pikku'
|
|
68
|
+
|
|
69
|
+
export const isBookOwner = pikkuPermission(
|
|
70
|
+
async ({ db }, { bookId }, { session }) => {
|
|
71
|
+
const book = await db.getBook(bookId)
|
|
72
|
+
return book?.authorId === session?.userId
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
export const hasBookAccess = pikkuPermission(
|
|
77
|
+
async ({ db }, { bookId }, { session }) => {
|
|
78
|
+
return await db.hasAccess(session?.userId, bookId)
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## OR / AND Logic
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
permissions: {
|
|
87
|
+
admin: isAdmin, // OR: admins can access
|
|
88
|
+
owner: isBookOwner, // OR: owners can access
|
|
89
|
+
reviewer: [isAuthenticated, hasBookAccess], // AND: both must pass
|
|
90
|
+
}
|
|
91
|
+
// Logic: admin OR owner OR (isAuthenticated AND hasBookAccess)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Groups are OR'd. Entries within a group array are AND'd.
|
|
95
|
+
|
|
96
|
+
## Where to Apply Permissions
|
|
97
|
+
|
|
98
|
+
### Per-Function (preferred)
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
export const deleteBook = pikkuFunc({
|
|
102
|
+
func: async ({ db }, { bookId }) => {
|
|
103
|
+
await db.deleteBook(bookId)
|
|
104
|
+
},
|
|
105
|
+
permissions: {
|
|
106
|
+
admin: isAdmin,
|
|
107
|
+
owner: isBookOwner,
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Pattern-Based (`addHTTPPermission`)
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
import { addHTTPPermission } from '@pikku/core/http'
|
|
116
|
+
|
|
117
|
+
addHTTPPermission('/admin/*', { admin: isAdmin })
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Tag-Based (`addPermission`)
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
import { addPermission } from '.pikku/pikku-types.gen.js'
|
|
124
|
+
|
|
125
|
+
addPermission('internal', { machine: isMachineAgent })
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## Complete Example
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
// src/permissions.ts
|
|
132
|
+
import { pikkuAuth, pikkuPermission } from '#pikku'
|
|
133
|
+
|
|
134
|
+
export const isAuthenticated = pikkuAuth(
|
|
135
|
+
async (_services, session) => !!session
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
export const isAdmin = pikkuAuth(
|
|
139
|
+
async (_services, session) => session?.role === 'admin'
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
export const isOrgMember = pikkuPermission(
|
|
143
|
+
async ({ db }, { orgId }, { session }) => {
|
|
144
|
+
return await db.isMember(session?.userId, orgId)
|
|
145
|
+
}
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
// src/functions/org.function.ts
|
|
149
|
+
export const deleteOrg = pikkuFunc({
|
|
150
|
+
func: async ({ db }, { orgId }) => {
|
|
151
|
+
await db.deleteOrg(orgId)
|
|
152
|
+
},
|
|
153
|
+
permissions: {
|
|
154
|
+
admin: isAdmin,
|
|
155
|
+
owner: [isAuthenticated, isOrgMember],
|
|
156
|
+
},
|
|
157
|
+
})
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## After Changes
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
pikku tsc # verify permission checker types are correct
|
|
164
|
+
pikku all # regenerate if wirings changed
|
|
165
|
+
```
|