@pikku/skills 0.12.2 → 0.12.6
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/dist/skills.gen.js +1 -1
- package/package.json +1 -1
- package/skills/pikku-addon/SKILL.md +56 -29
- package/skills/pikku-ai-agent/SKILL.md +197 -105
- package/skills/pikku-ai-vercel/SKILL.md +57 -18
- package/skills/pikku-ai-voice/SKILL.md +126 -52
- package/skills/pikku-audit/SKILL.md +35 -13
- package/skills/pikku-aws/SKILL.md +66 -16
- package/skills/pikku-backblaze/SKILL.md +44 -11
- package/skills/pikku-better-auth/SKILL.md +80 -34
- package/skills/pikku-cli/SKILL.md +67 -18
- package/skills/pikku-cli/references/complete-example.md +2 -0
- package/skills/pikku-concepts/SKILL.md +82 -10
- package/skills/pikku-concepts/references/concept-mapping.md +2 -2
- package/skills/pikku-config/SKILL.md +134 -52
- package/skills/pikku-cron/SKILL.md +13 -6
- package/skills/pikku-deploy-azure/SKILL.md +83 -28
- package/skills/pikku-deploy-cloudflare/SKILL.md +79 -37
- package/skills/pikku-deploy-express/SKILL.md +40 -4
- package/skills/pikku-deploy-fastify/SKILL.md +22 -1
- package/skills/pikku-deploy-lambda/SKILL.md +99 -19
- package/skills/pikku-deploy-nextjs/SKILL.md +49 -5
- package/skills/pikku-deploy-uws/SKILL.md +54 -1
- package/skills/pikku-deps/SKILL.md +29 -8
- package/skills/pikku-emails/SKILL.md +36 -5
- package/skills/pikku-fabric/SKILL.md +30 -5
- package/skills/pikku-fabric-debug/SKILL.md +5 -1
- package/skills/pikku-feature/SKILL.md +12 -7
- package/skills/pikku-gateway-slack/SKILL.md +72 -11
- package/skills/pikku-http/SKILL.md +18 -5
- package/skills/pikku-http/references/http-options.md +10 -5
- package/skills/pikku-i18n/SKILL.md +18 -7
- package/skills/pikku-info/SKILL.md +18 -8
- package/skills/pikku-jose/SKILL.md +35 -6
- package/skills/pikku-knowledge/SKILL.md +3 -3
- package/skills/pikku-kysely/SKILL.md +78 -15
- package/skills/pikku-machine-auth/SKILL.md +36 -1
- package/skills/pikku-mcp/SKILL.md +159 -149
- package/skills/pikku-middleware/SKILL.md +17 -5
- package/skills/pikku-mongodb/SKILL.md +10 -2
- package/skills/pikku-n8n-import/SKILL.md +14 -6
- package/skills/pikku-permissions/SKILL.md +102 -22
- package/skills/pikku-pino/SKILL.md +12 -4
- package/skills/pikku-product-second-opinion/SKILL.md +3 -3
- package/skills/pikku-queue/SKILL.md +45 -16
- package/skills/pikku-react/SKILL.md +41 -14
- package/skills/pikku-react-query/SKILL.md +14 -10
- package/skills/pikku-realtime/SKILL.md +44 -22
- package/skills/pikku-redis/SKILL.md +12 -3
- package/skills/pikku-rpc/SKILL.md +23 -12
- package/skills/pikku-rtl/SKILL.md +21 -17
- package/skills/pikku-scenario/SKILL.md +285 -50
- package/skills/pikku-schedule/SKILL.md +39 -6
- package/skills/pikku-schema-ajv/SKILL.md +24 -2
- package/skills/pikku-schema-cfworker/SKILL.md +22 -2
- package/skills/pikku-security/SKILL.md +54 -9
- package/skills/pikku-services/SKILL.md +49 -9
- package/skills/pikku-services/references/audit-wire-service.md +2 -1
- package/skills/pikku-software-archaeology/README.md +16 -6
- package/skills/pikku-software-archaeology/references/pikku-mapping.md +30 -30
- package/skills/pikku-template-clone/SKILL.md +10 -5
- package/skills/pikku-trigger/SKILL.md +50 -6
- package/skills/pikku-versioning/SKILL.md +46 -17
- package/skills/pikku-websocket/SKILL.md +72 -44
- package/skills/pikku-workflow/SKILL.md +35 -1
- package/skills/pikku-workflow/references/workflow-reference.md +13 -8
- package/skills/pikku-workflows-client/SKILL.md +13 -6
- package/skills/pikku-ws/SKILL.md +44 -8
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
name: pikku-ai-voice
|
|
3
3
|
description: >-
|
|
4
4
|
Use when adding voice input (speech-to-text) or voice output (text-to-speech) to AI agents in a
|
|
5
|
-
Pikku app. Covers voiceInput/voiceOutput middleware
|
|
6
|
-
TRIGGER when: code uses voiceInput, voiceOutput,
|
|
7
|
-
|
|
8
|
-
AI agent wiring (use pikku-ai-agent) or
|
|
5
|
+
Pikku app. Covers the voiceInput/voiceOutput AI middleware from @pikku/core/ai-agent, per-script
|
|
6
|
+
voices, and barge-in. TRIGGER when: code uses voiceInput, voiceOutput, or user asks about voice
|
|
7
|
+
agents, speech-to-text, text-to-speech, transcription, or @pikku/ai-voice. DO NOT TRIGGER when:
|
|
8
|
+
user asks about AI agent wiring generally (use pikku-ai-agent) or the runner itself (use
|
|
9
|
+
pikku-ai-vercel).
|
|
9
10
|
---
|
|
10
11
|
|
|
11
12
|
# Pikku AI Voice (Speech I/O)
|
|
@@ -20,69 +21,142 @@ Use this skill as an execution checklist, not reference material.
|
|
|
20
21
|
4. Validate with the narrowest relevant command first, then run `pikku-verify` or `pikku all` when functions, wirings, schemas, or generated clients may have changed.
|
|
21
22
|
5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.
|
|
22
23
|
|
|
23
|
-
`@pikku/ai-voice`
|
|
24
|
+
## `@pikku/ai-voice` is deprecated and empty
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
The package still publishes, but its entire source is `export {}` — there are no
|
|
27
|
+
`STTService`/`TTSService` interfaces and nothing to import. Do not add it as a
|
|
28
|
+
dependency.
|
|
26
29
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
Voice now lives in **`@pikku/core/ai-agent`** as two AI middlewares, and the
|
|
31
|
+
speech models are reached through the `aiAgentRunner` (`transcribe` /
|
|
32
|
+
`generateSpeech`) rather than through separate services. See `pikku-ai-vercel`.
|
|
30
33
|
|
|
31
34
|
## API Reference
|
|
32
35
|
|
|
33
|
-
### Service Interfaces
|
|
34
|
-
|
|
35
36
|
```typescript
|
|
36
|
-
|
|
37
|
-
transcribe(
|
|
38
|
-
audio: Uint8Array,
|
|
39
|
-
options?: { language?: string; format?: string }
|
|
40
|
-
): Promise<string>
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
interface TTSService {
|
|
44
|
-
synthesize(
|
|
45
|
-
text: string,
|
|
46
|
-
options?: { voice?: string; format?: string }
|
|
47
|
-
): Promise<Uint8Array>
|
|
48
|
-
synthesizeStream?(
|
|
49
|
-
text: string,
|
|
50
|
-
options?: { voice?: string; format?: string }
|
|
51
|
-
): AsyncIterable<Uint8Array>
|
|
52
|
-
}
|
|
53
|
-
```
|
|
37
|
+
import { voiceInput, voiceOutput } from '@pikku/core/ai-agent'
|
|
54
38
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
39
|
+
voiceInput(config?: {
|
|
40
|
+
model?: string // transcription model — required in practice
|
|
41
|
+
language?: string // forwarded as openai providerOptions.language
|
|
42
|
+
allowedAudioHosts?: string[] // allowlist for audio parts given as a URL
|
|
43
|
+
})
|
|
59
44
|
|
|
60
|
-
|
|
61
|
-
|
|
45
|
+
voiceOutput(config?: {
|
|
46
|
+
model?: string // speech model — required in practice
|
|
47
|
+
voice?: string
|
|
48
|
+
format?: string
|
|
49
|
+
instructions?: string
|
|
50
|
+
speed?: number
|
|
51
|
+
language?: string
|
|
52
|
+
speakableScripts?: string[] | Record<string, string>
|
|
53
|
+
always?: boolean
|
|
54
|
+
})
|
|
62
55
|
```
|
|
63
56
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
###
|
|
57
|
+
Both attach through the agent's **`aiMiddleware`** array, not a
|
|
58
|
+
`middlewareHooks` option, and the agent is declared with `pikkuAIAgent` — there
|
|
59
|
+
is no `wireAIAgent`.
|
|
60
|
+
|
|
61
|
+
### `voiceInput` — audio in, text in its place
|
|
62
|
+
|
|
63
|
+
It rewrites the last user message, replacing each `audio/*` file part with a
|
|
64
|
+
text part holding the transcript. Downstream nothing can tell the turn was
|
|
65
|
+
spoken, which is why it records two shared-notes keys on the way past:
|
|
66
|
+
|
|
67
|
+
- `SPOKEN_TURN` (`'voice:spokenTurn'`) — `true`/`false` on every turn it sees.
|
|
68
|
+
**Absent** when the middleware isn't wired at all, which is what lets
|
|
69
|
+
`voiceOutput` still speak for a caller that has no voice input.
|
|
70
|
+
- `SPOKEN_TRANSCRIPT` (`'voice:transcript'`) — what the user was heard to say,
|
|
71
|
+
only when something was heard. The stream wiring forwards it to the client as
|
|
72
|
+
a `transcript` event; a voice client has no other way to know what its own
|
|
73
|
+
audio said, and without it the user's turn renders as an empty bubble.
|
|
74
|
+
|
|
75
|
+
Behaviours that decide how a voice loop should be written:
|
|
76
|
+
|
|
77
|
+
- **It is a no-op without `aiAgentRunner.transcribe`** — no error, the audio
|
|
78
|
+
simply passes through untouched.
|
|
79
|
+
- **`config.model` is required once audio actually arrives**, and throws then
|
|
80
|
+
rather than at wiring time.
|
|
81
|
+
- **A turn that was entirely non-speech throws `NoSpeechDetectedError`.** Catch
|
|
82
|
+
it and go back to listening without running the agent — answering a
|
|
83
|
+
hallucinated sentence is worse than answering nothing. It is deliberately
|
|
84
|
+
distinct from a transcription failure, which is worth reporting.
|
|
85
|
+
- **Non-speech means an empty transcript, and nothing cleverer.** There was a
|
|
86
|
+
per-segment confidence gate here and it was removed: Whisper is
|
|
87
|
+
subtitle-trained, so it is *confident* when it invents ("Thank you." scored
|
|
88
|
+
better than the real sentence beside it). Pick an ASR that returns an empty
|
|
89
|
+
string on silence rather than trying to filter one that doesn't.
|
|
90
|
+
- Audio arrives either inline (base64 `data`) or as a `url` fetched through
|
|
91
|
+
`safeFetch`; either way 50MB is the ceiling.
|
|
92
|
+
|
|
93
|
+
### `voiceOutput` — sentence-at-a-time synthesis
|
|
94
|
+
|
|
95
|
+
It intercepts the output stream, buffers `text-delta`s to a sentence boundary,
|
|
96
|
+
and synthesizes each finished sentence immediately, so the first is playing while
|
|
97
|
+
the rest is still being written. Emissions are chained even though generation
|
|
98
|
+
overlaps, so the client hears them in order; on `done` it flushes the tail,
|
|
99
|
+
awaits the chain, and emits `audio-done` before the `done` event.
|
|
100
|
+
|
|
101
|
+
- **It speaks only in reply to speech** unless `always: true`. Only an explicit
|
|
102
|
+
`SPOKEN_TURN === false` silences it — the key being absent (no `voiceInput`
|
|
103
|
+
wired) still speaks. Set `always` for a read-aloud mode or a kiosk, where the
|
|
104
|
+
whole output is meant to be heard; leave it off for an agent serving both typed
|
|
105
|
+
and spoken callers, since synthesizing replies nobody is listening to costs
|
|
106
|
+
real money per sentence.
|
|
107
|
+
- **A failed sentence is logged and skipped**, not thrown — one silent sentence
|
|
108
|
+
beats the rest of the reply never arriving.
|
|
109
|
+
- **Barge-in aborts synthesis, not just playback**: the stream's `signal` is
|
|
110
|
+
passed to the speech model, so sentences in flight stop being billed.
|
|
111
|
+
|
|
112
|
+
### `speakableScripts` — declare what the model can pronounce
|
|
113
|
+
|
|
114
|
+
Handed a script it has no voice for, a speech model typically neither fails nor
|
|
115
|
+
stays quiet: Kokoro reads out the *letter names* — 24 seconds of "Arabic meem,
|
|
116
|
+
Arabic ra" for a one-line sentence. Declaring the range leaves anything outside
|
|
117
|
+
it unspoken and reports it once per reply as a `voice-unsupported` data event.
|
|
118
|
+
|
|
119
|
+
The record form maps script → voice, because a multilingual model usually needs
|
|
120
|
+
the matching voice too: asked for Chinese in a default American-English voice,
|
|
121
|
+
Kokoro spells the characters out in 9.9s where `zf_xiaobei` says it in 3.5.
|
|
122
|
+
|
|
123
|
+
Known scripts: `latin`, `devanagari`, `han`, `kana`, `arabic`, `cyrillic`,
|
|
124
|
+
`hangul`, `hebrew`, `greek`, `thai`. A sentence in several is settled by
|
|
125
|
+
precedence, not config order — `kana` first (it appears only in Japanese, so it
|
|
126
|
+
decides; `han` alone cannot), `latin` last (it turns up inside sentences in every
|
|
127
|
+
other script). Omitting the option means no check at all, which is right for a
|
|
128
|
+
genuinely multilingual provider.
|
|
129
|
+
|
|
130
|
+
`unspeakableScripts(text, speakable)` and `voiceForText(text, speakable,
|
|
131
|
+
fallback)` are exported if you need the same decision outside the middleware.
|
|
132
|
+
|
|
133
|
+
## Usage Pattern
|
|
69
134
|
|
|
70
135
|
```typescript
|
|
71
|
-
import {
|
|
72
|
-
import {
|
|
136
|
+
import { pikkuAIAgent } from '#pikku/agent/pikku-agent-types.gen.js'
|
|
137
|
+
import { voiceInput, voiceOutput } from '@pikku/core/ai-agent'
|
|
73
138
|
|
|
74
|
-
|
|
139
|
+
export const voiceAssistant = pikkuAIAgent({
|
|
75
140
|
name: 'voice-assistant',
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
141
|
+
description: 'Holds a spoken conversation',
|
|
142
|
+
goal: 'You are a voice assistant. You are being listened to, not read.',
|
|
143
|
+
model: 'openai/gpt-5-mini',
|
|
144
|
+
aiMiddleware: [
|
|
145
|
+
voiceInput({ model: 'deepinfra/openai/whisper-large-v3-turbo' }),
|
|
146
|
+
voiceOutput({
|
|
147
|
+
model: 'deepinfra/hexgrad/Kokoro-82M',
|
|
148
|
+
speakableScripts: {
|
|
149
|
+
han: 'zf_xiaobei',
|
|
150
|
+
kana: 'jf_alpha',
|
|
151
|
+
devanagari: 'hf_alpha',
|
|
152
|
+
latin: 'af_bella',
|
|
153
|
+
},
|
|
154
|
+
}),
|
|
81
155
|
],
|
|
82
|
-
func: myAgentFunc,
|
|
83
156
|
})
|
|
84
157
|
```
|
|
85
158
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
159
|
+
Write the goal for the ear: no lists, no markdown, no IDs read digit by digit.
|
|
160
|
+
The one thing worth spelling out is approvals — spoken aloud, the confirmation
|
|
161
|
+
sentence is all the user gets, so let `approvalDescription` on the tool produce
|
|
162
|
+
it and forbid the model from asking for permission in its own words.
|
|
@@ -20,16 +20,18 @@ installGroups: [core]
|
|
|
20
20
|
Use this skill as an execution checklist, not reference material.
|
|
21
21
|
|
|
22
22
|
1. Discover before editing. Check how services are wired (`services.ts`) and whether an `audit` table migration exists before adding audit calls.
|
|
23
|
-
2. NEVER hand-roll a custom `audit_log` / history table with direct `insertInto('audit_log')` calls. The framework owns audit. A bespoke table drifts from the runtime (missing
|
|
23
|
+
2. NEVER hand-roll a custom `audit_log` / history table with direct `insertInto('audit_log')` calls. The framework owns audit. A bespoke table drifts from the runtime (missing user/trace/wire context, hand-written CHECK constraints that reject valid events, no prod sink). Use the built-in path below.
|
|
24
24
|
3. Make the smallest source change: mark the function `audit: true`, inject `auditLog`, call `auditLog.write(...)`. Do not invent a new service.
|
|
25
25
|
4. Validate with `pikku all` (regenerates the service flags) then run the app / e2e.
|
|
26
26
|
|
|
27
27
|
## Mental model — two layers
|
|
28
28
|
|
|
29
29
|
- **`audit` (singleton `AuditService`)** — the durable **sink**. Write-only: `audit(event)` + optional `write(batch)`. Defaults to `NoopAuditService` (discards). Swap in a real sink to persist (see Sinks).
|
|
30
|
-
- **`auditLog` (wire service `AuditLog`)** — a per-invocation **buffer** built from the sink via `createInvocationAudit(audit, wire)`. `auditLog.write(input)` enriches each event with `functionId`, `wireType`, `traceId`, `occurredAt`, and `
|
|
30
|
+
- **`auditLog` (wire service `AuditLog`)** — a per-invocation **buffer** built from the sink via `createInvocationAudit(audit, wire)`. `auditLog.write(input)` enriches each event with `functionId`, `wireType`, `traceId`, `occurredAt`, and `userIdentity` (from the wire session) automatically, then flushes to the sink when the invocation ends.
|
|
31
31
|
|
|
32
|
-
An event only persists when the function opts in with **`audit: true`** — otherwise `auditLog` is a no-op that warns.
|
|
32
|
+
An event only persists when the function opts in with **`audit: true`** — otherwise `auditLog` is a no-op that warns once per invocation, naming the function that dropped the write.
|
|
33
|
+
|
|
34
|
+
`audit` also takes a config object, `{ durability: 'best-effort' | 'transactional' }`, and `audit: true` is shorthand for `'best-effort'`. Best-effort buffers events and flushes them when the invocation closes, swallowing sink failures with a warning — the function's result is never held hostage to the audit sink. `'transactional'` awaits the sink on every `write()` instead, so a sink failure fails the invocation. Reach for it only when losing the record is worse than failing the call.
|
|
33
35
|
|
|
34
36
|
## Wiring (services.ts)
|
|
35
37
|
|
|
@@ -46,15 +48,22 @@ export const createSingletonServices = pikkuServices(async (config, existing) =>
|
|
|
46
48
|
// a write from a function that forgot `audit: true` warns instead of vanishing.
|
|
47
49
|
export const createWireServices = pikkuWireServices(async (services, wire) => {
|
|
48
50
|
if (!services.audit) return {}
|
|
49
|
-
return {
|
|
51
|
+
return {
|
|
52
|
+
auditLog: createInvocationAudit(services.audit, wire, services.logger),
|
|
53
|
+
}
|
|
50
54
|
})
|
|
51
55
|
```
|
|
52
56
|
|
|
57
|
+
The optional third argument is the fallback logger for the dropped-write warning
|
|
58
|
+
and for best-effort flush failures. Without it those messages only surface when
|
|
59
|
+
the wire happens to carry a logger, which is how a missing `audit: true` goes
|
|
60
|
+
unnoticed.
|
|
61
|
+
|
|
53
62
|
`audit` and `auditLog` are already declared on `CoreSingletonServices` / `CoreServices`, so no type change is needed to inject them.
|
|
54
63
|
|
|
55
64
|
## Recording events — explicit domain events (default)
|
|
56
65
|
|
|
57
|
-
Mark the function `audit: true` and call `auditLog?.write(...)`. Domain history goes in `metadata`; the
|
|
66
|
+
Mark the function `audit: true` and call `auditLog?.write(...)`. Domain history goes in `metadata`; the user identity is derived from the session, so do NOT pass it manually.
|
|
58
67
|
|
|
59
68
|
```typescript
|
|
60
69
|
export const cancelInvoice = pikkuFunc({
|
|
@@ -82,12 +91,14 @@ export const cancelInvoice = pikkuFunc({
|
|
|
82
91
|
})
|
|
83
92
|
```
|
|
84
93
|
|
|
85
|
-
For a **system/cron** function there is no session, so `
|
|
94
|
+
For a **system/cron** function there is no session, so `userIdentity` is simply absent (nulls out `user_id`). Use `pikkuVoidFunc({ audit: true, func: async ({ auditLog }) => { ... } })` — the void/config form accepts `audit`.
|
|
86
95
|
|
|
87
96
|
Helper functions (in `lib/`) that record audit take `auditLog?: AuditLog` in their services arg and are passed it from a `audit: true` caller — never import a service.
|
|
88
97
|
|
|
89
98
|
Note: events buffer and flush on invocation close. For a write inside a DB transaction, call `auditLog.write()` **after** the transaction commits — the sink is not part of your `trx`, so only record committed state.
|
|
90
99
|
|
|
100
|
+
`write` is `Safe<>`-guarded the way the logger is: `input` and `metadata` are `unknown`, so a `SecretValue` nested anywhere in the event collapses the call to `never` and it stops compiling. An unrevealed secret would serialize as `[secret]` regardless — the guard just makes putting one in an audit row a decision rather than an accident. Reveal it explicitly if you genuinely mean to record it.
|
|
101
|
+
|
|
91
102
|
## Recording events — automatic query capture (optional)
|
|
92
103
|
|
|
93
104
|
To audit every DB mutation without explicit calls, wrap kysely so each query emits an event. Note this captures table/column changes only — it cannot see semantic events that do no DB write (e.g. "email sent"), so combine with explicit writes when you need those.
|
|
@@ -101,6 +112,11 @@ export const createWireServices = pikkuWireServices(async (services, wire) => {
|
|
|
101
112
|
})
|
|
102
113
|
```
|
|
103
114
|
|
|
115
|
+
It is a Kysely plugin, so it wraps the instance rather than replacing it. Only
|
|
116
|
+
mutations are captured by default; `auditReads: true` adds selects, which is
|
|
117
|
+
usually far more volume than it is worth. `eventType`, `transactionId` and
|
|
118
|
+
`queryIdPrefix` are also accepted for labelling the emitted events.
|
|
119
|
+
|
|
104
120
|
## Sinks
|
|
105
121
|
|
|
106
122
|
- **`NoopAuditService`** (`@pikku/core/services`) — default; discards events. Fine when audit isn't needed.
|
|
@@ -121,8 +137,9 @@ CREATE TABLE IF NOT EXISTS audit (
|
|
|
121
137
|
trace_id TEXT,
|
|
122
138
|
transaction_id TEXT,
|
|
123
139
|
query_id TEXT,
|
|
124
|
-
|
|
125
|
-
|
|
140
|
+
user_id TEXT,
|
|
141
|
+
org_id TEXT,
|
|
142
|
+
pikku_user_id TEXT,
|
|
126
143
|
tables TEXT, -- JSON: table names touched (auto capture)
|
|
127
144
|
changed_cols TEXT, -- JSON: changed column names (auto capture)
|
|
128
145
|
event TEXT, -- custom event label
|
|
@@ -131,12 +148,14 @@ CREATE TABLE IF NOT EXISTS audit (
|
|
|
131
148
|
);
|
|
132
149
|
```
|
|
133
150
|
|
|
151
|
+
The defaults above are SQLite; on Postgres swap them for `gen_random_uuid()::text` and `now()::text`. Every column stays TEXT on every engine so a locally-run project and a deployed stage write identical rows, and the sink inserts with `ON CONFLICT DO NOTHING` so a retried flush is idempotent.
|
|
152
|
+
|
|
134
153
|
`auditLog.write({ metadata })` lands in the `data` column. Read history back by filtering it (SQLite `json_extract`, Postgres `->>`):
|
|
135
154
|
|
|
136
155
|
```typescript
|
|
137
156
|
const rows = await kysely
|
|
138
157
|
.selectFrom('audit')
|
|
139
|
-
.leftJoin('user', 'user.id', 'audit.
|
|
158
|
+
.leftJoin('user', 'user.id', 'audit.userId')
|
|
140
159
|
.where(sql<boolean>`json_extract(audit.data, '$.entity') = 'invoice'`)
|
|
141
160
|
.where(sql<boolean>`json_extract(audit.data, '$.entityId') = ${invoiceId}`)
|
|
142
161
|
.orderBy('audit.occurredAt', 'desc')
|
|
@@ -156,20 +175,23 @@ type AuditEvent = {
|
|
|
156
175
|
type: string // e.g. 'invoice.update'
|
|
157
176
|
source: 'auto' | 'explicit'
|
|
158
177
|
occurredAt: string // auto-filled by auditLog
|
|
159
|
-
|
|
178
|
+
eventId?: string
|
|
179
|
+
outcome?: 'success' | 'failed' | 'denied'
|
|
160
180
|
functionId?; wireType?; wireId?; traceId?; transactionId?; queryId? // auto
|
|
161
|
-
|
|
181
|
+
userIdentity?: { userId?; orgId?; pikkuUserId? } // auto from wire session
|
|
162
182
|
input?: unknown
|
|
163
183
|
metadata?: Record<string, unknown> // your domain payload
|
|
164
184
|
}
|
|
165
185
|
```
|
|
166
186
|
|
|
167
|
-
`auditLog.write()` takes `Omit<AuditEvent, 'occurredAt'>` — you only supply `type`, `source`, and `metadata` (and `
|
|
187
|
+
`auditLog.write()` takes `Omit<AuditEvent, 'occurredAt'>` — you only supply `type`, `source`, and `metadata` (and `userIdentity` if overriding the session default).
|
|
188
|
+
|
|
189
|
+
`userIdentity` is filled from the wire's session plus its `pikkuUserId`, and is left off entirely when all three are absent — which is what makes a cron or system invocation land with a null actor rather than an empty object.
|
|
168
190
|
|
|
169
191
|
## Do / Don't
|
|
170
192
|
|
|
171
193
|
- DO mark recording functions `audit: true`, inject `auditLog`, and call `auditLog.write({ type, source: 'explicit', metadata })`.
|
|
172
|
-
- DO let the
|
|
194
|
+
- DO let the user identity come from the session — don't thread `userId` into metadata for it.
|
|
173
195
|
- DON'T create a custom `audit_log`/history table or `insertInto('audit_log')` by hand.
|
|
174
196
|
- DON'T annotate the function's I/O from audit; audit is a side channel, not part of `input`/`output`.
|
|
175
197
|
- DON'T write audit inside a DB transaction expecting rollback — record after commit.
|
|
@@ -36,52 +36,102 @@ yarn add @pikku/aws-services
|
|
|
36
36
|
import { S3Content } from '@pikku/aws-services'
|
|
37
37
|
|
|
38
38
|
const content = new S3Content(
|
|
39
|
-
config:
|
|
39
|
+
config: { bucketName: string; region: string; endpoint?: string },
|
|
40
40
|
logger: Logger,
|
|
41
41
|
signConfig: { keyPairId: string; privateKey: string }
|
|
42
42
|
)
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
- `
|
|
51
|
-
- `
|
|
52
|
-
- `
|
|
53
|
-
- `
|
|
54
|
-
- `
|
|
45
|
+
`endpoint` is what points the client at LocalStack or an S3-compatible store.
|
|
46
|
+
|
|
47
|
+
**Methods** — every one takes a single **args object**, matching the shared
|
|
48
|
+
`ContentService` interface. None of them are positional:
|
|
49
|
+
|
|
50
|
+
- `signURL({ url, dateLessThan, dateGreaterThan? }): Promise<string>` — CloudFront-sign an absolute URL
|
|
51
|
+
- `signContentKey({ bucket, contentKey, dateLessThan, dateGreaterThan? }): Promise<string>`
|
|
52
|
+
- `getUploadURL({ bucket, fileKey, contentType, visibility? }): Promise<{ uploadUrl, assetKey }>` — `visibility` is ignored
|
|
53
|
+
- `readFile({ bucket, key }): Promise<ReadableStream | NodeJS.ReadableStream>`
|
|
54
|
+
- `readFileAsBuffer({ bucket, key }): Promise<Buffer>`
|
|
55
|
+
- `writeFile({ bucket, key, stream }): Promise<boolean>`
|
|
56
|
+
- `copyFile({ bucket, key, fromAbsolutePath }): Promise<boolean>`
|
|
57
|
+
- `deleteFile({ bucket, key }): Promise<boolean>`
|
|
58
|
+
|
|
59
|
+
### One real bucket, logical buckets as prefixes
|
|
60
|
+
|
|
61
|
+
The `bucket` on every call is a **logical** bucket stored as a path prefix
|
|
62
|
+
(`${bucket}/${key}`) inside the single S3 bucket named by `bucketName`. Don't
|
|
63
|
+
provision an S3 bucket per logical bucket — the config takes only one.
|
|
64
|
+
|
|
65
|
+
### Behaviours worth knowing before you rely on them
|
|
66
|
+
|
|
67
|
+
- **`signURL` fails open.** A signing error is logged and the *unsigned* URL is
|
|
68
|
+
returned rather than thrown. If your CloudFront distribution is private the
|
|
69
|
+
client then gets a 403; if it isn't, you have just handed out an unrestricted
|
|
70
|
+
link. Check that `signConfig` is a valid CloudFront key pair at boot.
|
|
71
|
+
- **`signContentKey` builds `https://<bucketName>/<bucket>/<contentKey>`** — it
|
|
72
|
+
uses `bucketName` as the *host*. For signed content the value must therefore be
|
|
73
|
+
your CloudFront domain, not a plain bucket name, which also means the same
|
|
74
|
+
config field is doing two jobs.
|
|
75
|
+
- **Presigned upload URLs expire after a fixed 3600s.** It is not configurable
|
|
76
|
+
through the service.
|
|
77
|
+
- **Write paths swallow failures.** `writeFile`, `copyFile` and `deleteFile` log
|
|
78
|
+
and return `false` rather than throwing; the read paths throw. Check the
|
|
79
|
+
boolean.
|
|
55
80
|
|
|
56
81
|
### `SQSQueueService` (Queue)
|
|
57
82
|
|
|
58
83
|
```typescript
|
|
59
84
|
import { SQSQueueService } from '@pikku/aws-services'
|
|
60
85
|
|
|
61
|
-
const queue = new SQSQueueService(
|
|
86
|
+
const queue = new SQSQueueService({
|
|
87
|
+
region: string,
|
|
88
|
+
queueUrlPrefix: string, // e.g. 'https://sqs.us-east-1.amazonaws.com/123456789/'
|
|
89
|
+
endpoint?: string, // LocalStack or a custom SQS endpoint
|
|
90
|
+
})
|
|
62
91
|
```
|
|
63
92
|
|
|
64
93
|
Implements `QueueService`. Note: `supportsResults = false` — job status tracking is not supported.
|
|
65
94
|
|
|
66
95
|
**Methods:**
|
|
67
96
|
|
|
68
|
-
- `add<T>(queueName: string, data: T, options?: JobOptions): Promise<string>` — Enqueue a message
|
|
97
|
+
- `add<T>(queueName: string, data: T, options?: JobOptions): Promise<string>` — Enqueue a message; returns SQS's `MessageId`
|
|
98
|
+
- `getJob()` — always **throws**. SQS is fire-and-forget; reach for BullMQ or PgBoss when you need the result back.
|
|
99
|
+
|
|
100
|
+
The queue URL is `queueUrlPrefix + queueName`, so the queue name in `wireQueueWorker` has to match the SQS queue exactly.
|
|
101
|
+
|
|
102
|
+
Constraints inherited from SQS, enforced in `add`:
|
|
103
|
+
|
|
104
|
+
- `options.delay` is in **milliseconds** and is floored to whole seconds. Over
|
|
105
|
+
900_000ms (15 minutes) or negative throws before the message is sent.
|
|
106
|
+
- Standard queues only — no FIFO, so no `MessageGroupId` and no ordering
|
|
107
|
+
guarantee.
|
|
108
|
+
- `data` is `JSON.stringify`d, which is where a `Date` or a `Map` quietly
|
|
109
|
+
degrades.
|
|
69
110
|
|
|
70
111
|
### `AWSSecrets` (Secrets Manager)
|
|
71
112
|
|
|
72
113
|
```typescript
|
|
73
114
|
import { AWSSecrets } from '@pikku/aws-services'
|
|
74
115
|
|
|
75
|
-
const secrets = new AWSSecrets(
|
|
116
|
+
const secrets = new AWSSecrets({ awsRegion: 'eu-west-2' })
|
|
76
117
|
```
|
|
77
118
|
|
|
119
|
+
`AWSConfig` has one field, `awsRegion` — there is no credentials option; the SDK's
|
|
120
|
+
default provider chain (instance role, env, profile) supplies those.
|
|
121
|
+
|
|
78
122
|
**Methods:**
|
|
79
123
|
|
|
80
|
-
- `getSecret<T = string>(SecretId: string): Promise<T
|
|
124
|
+
- `getSecret<T = string>(SecretId: string): Promise<SecretValue<T>>` — a JSON secret is parsed automatically, so pass a shape as `T` (a non-JSON value comes back as the raw string). The result is a branded `SecretValue`, not a bare value — reveal it where it is used rather than passing it through logs
|
|
81
125
|
- `getSecrets<T>(SecretIds: (keyof T & string)[]): Promise<Partial<T>>` — Batch fetch; missing keys are omitted rather than thrown
|
|
82
126
|
- `hasSecret(SecretId: string): Promise<boolean>` — Check if secret exists
|
|
83
127
|
- `setSecret` / `deleteSecret` — **not implemented** for `AWSSecrets`; it throws. Manage AWS secrets out of band.
|
|
84
128
|
|
|
129
|
+
Every `getSecret` failure — missing secret, denied permission, a secret holding
|
|
130
|
+
only binary — surfaces as the same `FATAL: Error finding secret: <id>`, with the
|
|
131
|
+
real reason on the error's `cause`. Read `cause` before concluding the secret
|
|
132
|
+
doesn't exist. `hasSecret` performs a full fetch and returns `false` for any
|
|
133
|
+
error, so it can't distinguish "absent" from "not allowed" either.
|
|
134
|
+
|
|
85
135
|
## Usage Patterns
|
|
86
136
|
|
|
87
137
|
### S3 Content Service
|
|
@@ -90,7 +140,7 @@ const secrets = new AWSSecrets(config: AWSConfig)
|
|
|
90
140
|
const createSingletonServices = pikkuServices(async (config) => {
|
|
91
141
|
const logger = new PinoLogger()
|
|
92
142
|
const content = new S3Content(
|
|
93
|
-
{
|
|
143
|
+
{ bucketName: config.s3Bucket, region: config.awsRegion },
|
|
94
144
|
logger,
|
|
95
145
|
{ keyPairId: config.cfKeyPairId, privateKey: config.cfPrivateKey }
|
|
96
146
|
)
|
|
@@ -39,16 +39,41 @@ const content = new B2Content(
|
|
|
39
39
|
)
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
- `
|
|
50
|
-
- `
|
|
51
|
-
- `
|
|
42
|
+
`B2ContentConfig` has exactly three fields — `applicationKeyId`, `applicationKey`
|
|
43
|
+
and `bucketId`. There is no `cdnUrl`: downloads are served from the `downloadUrl`
|
|
44
|
+
B2 returns at authorization.
|
|
45
|
+
|
|
46
|
+
**Methods** — every one takes a single **args object**, matching the shared
|
|
47
|
+
`ContentService` interface. None of them are positional:
|
|
48
|
+
|
|
49
|
+
- `signContentKey({ bucket, contentKey, dateLessThan }): Promise<string>` — a full download URL with an `Authorization` query param
|
|
50
|
+
- `signURL({ url, dateLessThan }): Promise<string>` — re-signs an existing `/file/` URL; a URL with no `/file/` segment is returned untouched
|
|
51
|
+
- `getUploadURL({ bucket, fileKey, contentType, visibility? }): Promise<UploadURLResult>` — `visibility` is ignored by this backend
|
|
52
|
+
- `writeFile({ bucket, key, stream }): Promise<boolean>`
|
|
53
|
+
- `copyFile({ bucket, key, fromAbsolutePath }): Promise<boolean>`
|
|
54
|
+
- `readFile({ bucket, key }): Promise<ReadableStream | NodeJS.ReadableStream>`
|
|
55
|
+
- `readFileAsBuffer({ bucket, key }): Promise<Buffer>`
|
|
56
|
+
- `deleteFile({ bucket, key }): Promise<boolean>`
|
|
57
|
+
|
|
58
|
+
### One real bucket, logical buckets as prefixes
|
|
59
|
+
|
|
60
|
+
The `bucket` on every call is a **logical** bucket stored as a path prefix
|
|
61
|
+
(`${bucket}/${key}`) inside the single B2 bucket named by `bucketId`. Don't
|
|
62
|
+
provision a B2 bucket per logical bucket — the config takes only one.
|
|
63
|
+
|
|
64
|
+
### Behaviours worth knowing before you rely on them
|
|
65
|
+
|
|
66
|
+
- **Writes are buffered in memory.** `writeFile` drains the whole stream into a
|
|
67
|
+
`Buffer` before uploading, because B2's upload endpoint needs a SHA-1 and a
|
|
68
|
+
content length up front. Large uploads should go through `getUploadURL` and be
|
|
69
|
+
sent by the client directly.
|
|
70
|
+
- **`getUploadURL` sets `X-Bz-Content-Sha1: do_not_verify`**, since the server
|
|
71
|
+
can't hash a body it never sees. The client-side upload is unverified.
|
|
72
|
+
- **Write paths swallow failures.** `writeFile`, `copyFile` and `deleteFile` log
|
|
73
|
+
and return `false` rather than throwing; the read paths and the signing paths
|
|
74
|
+
throw. Check the boolean — an ignored return is a silently lost file.
|
|
75
|
+
- Authorization and the bucket-name lookup are cached on the instance for its
|
|
76
|
+
lifetime, so a rotated application key needs a new `B2Content`.
|
|
52
77
|
|
|
53
78
|
## Usage Patterns
|
|
54
79
|
|
|
@@ -62,10 +87,18 @@ const createSingletonServices = pikkuServices(async (config) => {
|
|
|
62
87
|
applicationKeyId: config.b2KeyId,
|
|
63
88
|
applicationKey: config.b2AppKey,
|
|
64
89
|
bucketId: config.b2BucketId,
|
|
65
|
-
cdnUrl: config.b2CdnUrl,
|
|
66
90
|
},
|
|
67
91
|
logger
|
|
68
92
|
)
|
|
69
93
|
return { config, logger, content }
|
|
70
94
|
})
|
|
71
95
|
```
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
await content.writeFile({ bucket: 'avatars', key: `${userId}.png`, stream })
|
|
99
|
+
const url = await content.signContentKey({
|
|
100
|
+
bucket: 'avatars',
|
|
101
|
+
contentKey: `${userId}.png`,
|
|
102
|
+
dateLessThan: new Date(Date.now() + 60_000),
|
|
103
|
+
})
|
|
104
|
+
```
|