@dvmkit/dvmctl 0.1.0-rc.2 → 0.2.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +1983 -0
- package/README.md +34 -1
- package/dist/dvmctl.js +106 -59
- package/package.json +9 -4
- package/skills/build-dvm/SKILL.md +93 -0
- package/skills/build-dvm/references/configure-context.md +273 -0
- package/skills/build-dvm/references/local-cashu-test.md +141 -0
- package/skills/build-dvm/references/operating-feedback.md +153 -0
- package/skills/build-dvm/references/patterns-auth.md +390 -0
- package/skills/build-dvm/references/payment-rails.md +11 -0
- package/skills/build-dvm/references/pricing-credit.md +155 -0
- package/skills/build-dvm/references/running-deployment.md +231 -0
- package/skills/build-dvm/references/sdk-reference.md +47 -0
- package/skills/build-dvm/references/testing.md +181 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
## Operating a deployed DVM
|
|
2
|
+
|
|
3
|
+
Once a DVM is live, `dvmctl` is the full operational surface — you never need `flyctl`.
|
|
4
|
+
Every command emits JSON by default and takes `--human`; `<slug>` is the DVM's deployed name.
|
|
5
|
+
Exceptions noted inline (`logs` and `revenue csv` emit raw output, not JSON).
|
|
6
|
+
|
|
7
|
+
Every DVM-specific command resolves its bare `<slug>` only inside the active `dvmctl switch` context. Pass `--org <handle>` for a one-shot override without changing that context. The CLI resolves that scoped slug to an immutable DVM ID before it reads, changes, or opens a machine resource, so a matching slug in another org is never selected accidentally.
|
|
8
|
+
|
|
9
|
+
### Fleet introspection
|
|
10
|
+
|
|
11
|
+
| Command | Purpose | Key flags |
|
|
12
|
+
| ----------------------- | ------------------------------------------------------------ | --------------------------- |
|
|
13
|
+
| `dvmctl list` | List your deployed DVMs | |
|
|
14
|
+
| `dvmctl info <slug>` | Deployed-DVM details | `--org` |
|
|
15
|
+
| `dvmctl status <slug>` | Live Fly runtime state (machines, region, image, health) | `--org` |
|
|
16
|
+
| `dvmctl metrics <slug>` | Request metrics | `--hours` (default 24), `--org` |
|
|
17
|
+
| `dvmctl events <slug>` | Recent events (deploys, restarts, scale, env, machine state) | `--since` (e.g. `2h`, `7d`), `--org` |
|
|
18
|
+
|
|
19
|
+
### Runtime ops
|
|
20
|
+
|
|
21
|
+
| Command | Purpose | Key flags |
|
|
22
|
+
| --------------------------- | --------------------------------------------------------------- | ---------------------------------- |
|
|
23
|
+
| `dvmctl logs <slug>` | View or stream logs (**raw output**, not JSON) | `--tail`, `--since`, `--lines`, `--org` |
|
|
24
|
+
| `dvmctl ssh <slug>` | Interactive shell, or one-shot command, on the Fly machine | `--command`, `--machine`, `--org` |
|
|
25
|
+
| `dvmctl restart <slug>` | Restart machines without a redeploy | `--machine` (default: all), `--org` |
|
|
26
|
+
| `dvmctl rollback <slug>` | Revert to a previous successful deploy's image | `--to <deploy-id>` (default: prev), `--org` |
|
|
27
|
+
| `dvmctl scale <slug>` | Change machine count and/or VM size without a redeploy | `--count` (1–8), `--size`, `--org` |
|
|
28
|
+
| `dvmctl destroy <slug>` | Destroy the DVM and its paired Postgres cluster | `--confirm`, `--keep-db`, `--org` |
|
|
29
|
+
| `dvmctl deploy-status <id>` | Status of a deploy by ID — recovery handle for a stuck `deploy` | |
|
|
30
|
+
|
|
31
|
+
### Config, data & tracing
|
|
32
|
+
|
|
33
|
+
| Command | Purpose | Key flags |
|
|
34
|
+
| ----------------------------------- | ----------------------------------------------------------------------------- | ------------------------------ |
|
|
35
|
+
| `dvmctl env <slug>` | View or update environment variables | `--set KEY=val`, `--unset KEY`, `--org` |
|
|
36
|
+
| `dvmctl secrets list <slug>` | List secret names + last-applied times (never values) | `--org` |
|
|
37
|
+
| `dvmctl db connect <slug>` | Interactive `psql` against the DVM's database (needs local `psql`) | `--machine`, `--org` |
|
|
38
|
+
| `dvmctl db proxy <slug>` | Bind a local port to the DVM's database (`pg_dump`, drizzle-kit, GUI clients) | `--port`, `--machine`, `--org` |
|
|
39
|
+
| `dvmctl trace get <traceId>` | Fetch a trace by ID (isolate-dataset spans) | `--no-color` |
|
|
40
|
+
|
|
41
|
+
### Custom domains
|
|
42
|
+
|
|
43
|
+
| Command | Purpose |
|
|
44
|
+
| --------------------------------------- | ----------------------------------------------- |
|
|
45
|
+
| `dvmctl domains add <slug> <domain>` | Attach a custom domain (e.g. `api.example.com`); `--org` selects its org |
|
|
46
|
+
| `dvmctl domains list <slug>` | List attached domains; `--org` selects its org |
|
|
47
|
+
| `dvmctl domains verify <slug> <domain>` | Verify DNS/TLS provisioning; `--org` selects its org |
|
|
48
|
+
| `dvmctl domains remove <slug> <domain>` | Remove a custom domain; `--org` selects its org |
|
|
49
|
+
|
|
50
|
+
### Revenue & payout
|
|
51
|
+
|
|
52
|
+
Reporting, the path to collect your earnings off the Cashu accumulator, and the operator queues for refunds
|
|
53
|
+
and settlements no automatic loop will ever clear.
|
|
54
|
+
|
|
55
|
+
| Command | Purpose | Key flags |
|
|
56
|
+
| -------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------ |
|
|
57
|
+
| `dvmctl revenue summary` | Totals + per-rail breakdown | `--from`, `--to` (required), `--currency` |
|
|
58
|
+
| `dvmctl revenue events` | Individual revenue events | `--from`, `--to`, `--rail`, `--limit` |
|
|
59
|
+
| `dvmctl revenue csv` | Export revenue as CSV (**raw CSV output**) | `--from`, `--to` (required), `--currency`, `--rail`, `--out` |
|
|
60
|
+
| `dvmctl set-payout <handle> <ln-addr>` | Persist the default Lightning payout address for `melt-pending` | `--skip-preflight`, `--self-hosted`, `--org` |
|
|
61
|
+
| `dvmctl melt-pending <handle>` | Drain pending Cashu accumulator batches to the Lightning payout addr | `--payout`, `--endpoint`, `--watch`, `--interval`, `--org` |
|
|
62
|
+
| `dvmctl rotate <handle>` | Rotate the per-DVM Cashu lock pubkey (publishes next BIP-32 child) | `--endpoint`, `--org` |
|
|
63
|
+
| `dvmctl credit blocked <handle>` | Lightning top-ups the ledger could never credit — the operator queue | `--include-resolved`, `--limit`, `--after`, `--endpoint`, `--org` |
|
|
64
|
+
| `dvmctl credit reconcile <handle> <payment-hash>` | Credit a blocked payment onto a credit the caller holds | `--credit-id` (required), `--endpoint`, `--org` |
|
|
65
|
+
| `dvmctl credit write-off <handle> <payment-hash>` | Reviewed, not crediting — moves no money, reversible | `--note`, `--endpoint`, `--org` |
|
|
66
|
+
| `dvmctl credit drains-owed <handle>` | One-payment stablecoin refunds you owe (channel-backed ones settle themselves) | `--endpoint`, `--org` |
|
|
67
|
+
| `dvmctl credit drain-settle <handle> <credit-id> <drain-id>` | Record a refund you sent on chain — idempotent | `--tx` (required), `--note`, `--endpoint`, `--org` |
|
|
68
|
+
| `dvmctl credit x402-wedged <handle>` | x402 refunds that settled on chain but never booked — the repair queue | `--include-resolved`, `--limit`, `--after`, `--min-age-ms`, `--no-evidence`, `--endpoint`, `--org` |
|
|
69
|
+
| `dvmctl credit x402-reconcile <handle> <settlement-id>` | Finish one at the chain's own figure — never one you supply | `--endpoint`, `--org` |
|
|
70
|
+
| `dvmctl credit x402-write-off <handle> <settlement-id>` | Reviewed, not booking — moves no money, reversible | `--note`, `--endpoint`, `--org` |
|
|
71
|
+
| `dvmctl credit tempo-wedged <handle>` | Tempo closes that landed on chain but never booked the drain | `--include-resolved`, `--limit`, `--after`, `--min-age-ms`, `--no-evidence`, `--endpoint`, `--org` |
|
|
72
|
+
| `dvmctl credit tempo-reconcile <handle> <credit-id> <drain-id>` | Book one against the close receipt's own refund figure | `--endpoint`, `--org` |
|
|
73
|
+
| `dvmctl credit tempo-write-off <handle> <credit-id> <drain-id>` | Reviewed, not booking — moves no money, reversible | `--note`, `--endpoint`, `--org` |
|
|
74
|
+
|
|
75
|
+
### Account, org & auth
|
|
76
|
+
|
|
77
|
+
| Command | Purpose | Key flags |
|
|
78
|
+
| ------------------------------ | -------------------------------------------------------- | -------------------------------- |
|
|
79
|
+
| `dvmctl auth` | Authenticate with the platform | `--status`, `--logout` |
|
|
80
|
+
| `dvmctl switch [handle]` | Set the active org context for deploys (omit = show) | `--personal` (clear) |
|
|
81
|
+
| `dvmctl transfer <slug> <to>` | Transfer a DVM to an org (by handle) or back to personal | `--create-org`, `--display-name`, `--org` |
|
|
82
|
+
| `dvmctl api-keys list` | List builder API keys | |
|
|
83
|
+
| `dvmctl api-keys create` | Create a builder API key | `--label` |
|
|
84
|
+
| `dvmctl api-keys revoke <id>` | Revoke a builder API key | |
|
|
85
|
+
| `dvmctl handle set <handle>` | Claim a builder handle for `*.dvmkit.ai` subdomains | |
|
|
86
|
+
| `dvmctl handle check <handle>` | Check handle availability | |
|
|
87
|
+
| `dvmctl identity create` | Generate a per-workstation secp256k1 builder identity | `--force` |
|
|
88
|
+
| `dvmctl identity show` | Print the builder identity pubkey | |
|
|
89
|
+
|
|
90
|
+
## Caller feedback
|
|
91
|
+
|
|
92
|
+
Callers can send signed-envelope feedback about a DVM via `dvm feedback --dvm <slug> "..."` (or
|
|
93
|
+
`--job <jobId>` to attach a trace_id). The platform persists every submission per-slug; as the
|
|
94
|
+
builder, you read and toggle collection through `dvmctl feedback`.
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
dvmctl feedback list <slug> # newest-first, JSON
|
|
98
|
+
dvmctl feedback list <slug> --since 24h # relative duration ("24h", "7d") or ISO 8601
|
|
99
|
+
dvmctl feedback list <slug> --limit 100 --cursor <c> --org acme
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`list` is cursor-paginated, capped at 100 per page (default 50). Walk older pages by passing the
|
|
103
|
+
response's `next_cursor` back via `--cursor`. JSON shape:
|
|
104
|
+
|
|
105
|
+
```json
|
|
106
|
+
{
|
|
107
|
+
"slug": "...",
|
|
108
|
+
"feedback": [
|
|
109
|
+
{ "id": "...", "slug": "...", "created_at": "...", "caller_pubkey": "...",
|
|
110
|
+
"body": "...", "trace_id": "..." | null, "job_id": "..." | null }
|
|
111
|
+
],
|
|
112
|
+
"next_cursor": "..." | undefined
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Toggle collection per-DVM:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
dvmctl feedback enable <slug> # flips dvms.feedback_enabled = true
|
|
120
|
+
dvmctl feedback disable <slug> # flips dvms.feedback_enabled = false
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
When disabled, subsequent caller submissions to that slug fail with `409 feedback_disabled` and a
|
|
124
|
+
`display` field the calling agent can relay to its user. `list` keeps returning previously-stored
|
|
125
|
+
rows either way — the toggle gates collection, not retrieval.
|
|
126
|
+
|
|
127
|
+
All three commands require `dvmctl auth` (org-scoped); `list` only returns feedback for DVMs owned
|
|
128
|
+
by the authenticated org.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Verification
|
|
133
|
+
|
|
134
|
+
After writing or modifying DVM code, run the generated project's test command. Add project-owned
|
|
135
|
+
`lint` and `typecheck` scripts before relying on them, then run all three from the project directory:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
npm run lint
|
|
139
|
+
npm run typecheck
|
|
140
|
+
npm test
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**If the capability charges and calls a paid third-party API, there is a fourth rung** — the
|
|
144
|
+
[live provider contract](testing.md#live-provider-contracts):
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
<PROVIDER_KEY>=… npm run test:providers:live
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
This is a project-owned opt-in script: add it as described in the
|
|
151
|
+
[live provider contract](testing.md#live-provider-contracts). It is key-gated and spends real vendor
|
|
152
|
+
money, so it skips cleanly without the key. If you don't have one, **say so** and hand the operator
|
|
153
|
+
a green static pass — don't report the provider contract as verified when the suite skipped.
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
## Patterns
|
|
2
|
+
|
|
3
|
+
### Single-turn (simplest)
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
import { configureDVM } from "@dvmkit/sdk";
|
|
7
|
+
|
|
8
|
+
export default configureDVM({
|
|
9
|
+
name: "Uppercase",
|
|
10
|
+
capability: "uppercase",
|
|
11
|
+
tags: ["text"],
|
|
12
|
+
|
|
13
|
+
onJob(ctx) {
|
|
14
|
+
ctx.complete(ctx.input.toUpperCase());
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Multi-turn linear
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
import { configureDVM } from "@dvmkit/sdk";
|
|
23
|
+
|
|
24
|
+
export default configureDVM({
|
|
25
|
+
name: "Translator",
|
|
26
|
+
capability: "translate",
|
|
27
|
+
tags: ["translation"],
|
|
28
|
+
price: "$0.02",
|
|
29
|
+
state: { detectedLanguage: "", targetLanguage: "" },
|
|
30
|
+
|
|
31
|
+
async onJob(ctx) {
|
|
32
|
+
const detected = await ctx.step("detect", async () => detectLanguage(ctx.input));
|
|
33
|
+
ctx.state.detectedLanguage = detected;
|
|
34
|
+
ctx.text(`Detected: ${detected}`);
|
|
35
|
+
|
|
36
|
+
const lang = await ctx.prompt("target-lang", `Translate to?`, {
|
|
37
|
+
options: ["Spanish", "French", "German"],
|
|
38
|
+
});
|
|
39
|
+
ctx.state.targetLanguage = lang.text;
|
|
40
|
+
|
|
41
|
+
ctx.text(`Translating to ${lang.text}...`);
|
|
42
|
+
const result = await ctx.step("translate", async () => translate(ctx.input, lang.text));
|
|
43
|
+
|
|
44
|
+
ctx.artifact({ data: result, mime_type: "text/plain" });
|
|
45
|
+
ctx.complete(`Translated to ${lang.text}`);
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Event-driven (complex routing)
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
import { configureDVM } from "@dvmkit/sdk";
|
|
54
|
+
|
|
55
|
+
export default configureDVM({
|
|
56
|
+
name: "Translator",
|
|
57
|
+
capability: "translate",
|
|
58
|
+
tags: ["translation"],
|
|
59
|
+
state: { original: "" },
|
|
60
|
+
|
|
61
|
+
onJob(ctx) {
|
|
62
|
+
ctx.state.original = ctx.input;
|
|
63
|
+
ctx.prompt("lang", "What language?", {
|
|
64
|
+
options: ["Spanish", "French", "German"],
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
async onResponse(ctx, content) {
|
|
69
|
+
const result = await translate(ctx.state.original, content.text);
|
|
70
|
+
ctx.complete(result);
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### Multi-capability DVM (cast's pattern)
|
|
76
|
+
|
|
77
|
+
When several related operations share resources (a Postgres pool, an auth verifier, an S3
|
|
78
|
+
bucket), declare them as capabilities on one DVM instead of mounting separate DVMs. This is what
|
|
79
|
+
cast does — a podcast-feed DVM with one descriptor-level auth scheme covering four capabilities:
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
import { configureDVM } from "@dvmkit/sdk";
|
|
83
|
+
import { secp256k1Auth } from "@dvmkit/sdk/server";
|
|
84
|
+
|
|
85
|
+
import { addEpisodeSchema, deleteEpisodeSchema, updateFeedSchema } from "./types";
|
|
86
|
+
|
|
87
|
+
export function createCastDVM(runtime: CastRuntime) {
|
|
88
|
+
return configureDVM({
|
|
89
|
+
name: "cast",
|
|
90
|
+
description: "Host RSS feeds and episodes for podcast distribution",
|
|
91
|
+
tags: ["podcast", "rss", "feed", "audio"],
|
|
92
|
+
idleTimeout: 1800,
|
|
93
|
+
|
|
94
|
+
// Descriptor-level auth: one scheme gates every capability (see below).
|
|
95
|
+
auth: secp256k1Auth({ replayStore: runtime.replays, driftSeconds: 300 }),
|
|
96
|
+
|
|
97
|
+
onBoot(env) {
|
|
98
|
+
// validate env; runtime is already built by the time we reach here
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
capabilities: {
|
|
102
|
+
"add-episode": {
|
|
103
|
+
description: "Add an episode to a private RSS feed by audio URL",
|
|
104
|
+
input: addEpisodeSchema,
|
|
105
|
+
onQuote: {
|
|
106
|
+
schema: addEpisodeSchema,
|
|
107
|
+
handler: async (ctx) => quoteAddEpisode(ctx, runtime),
|
|
108
|
+
},
|
|
109
|
+
async onJob(ctx) {
|
|
110
|
+
await runAddEpisodeJob(ctx, runtime);
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
"update-feed": {
|
|
115
|
+
description: "Update per-feed metadata",
|
|
116
|
+
input: updateFeedSchema,
|
|
117
|
+
async onJob(ctx) {
|
|
118
|
+
await runUpdateFeedJob(ctx, runtime);
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
"delete-episode": {
|
|
123
|
+
description: "Delete a single episode",
|
|
124
|
+
input: deleteEpisodeSchema,
|
|
125
|
+
async onJob(ctx) {
|
|
126
|
+
await runDeleteEpisodeJob(ctx, runtime);
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
routes(app, { authAudience }) {
|
|
132
|
+
// see "Custom HTTP routes" below
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Callers target a capability by passing `capability: "add-episode"` on the job submission; the
|
|
139
|
+
wire (`POST /v1/job`) always carries the capability name. Each capability has its own input
|
|
140
|
+
schema, pricing, and quote handler; the DVM-level `auth`, `paymentMethods`, and `routes` are
|
|
141
|
+
shared.
|
|
142
|
+
|
|
143
|
+
### Stateful DVM — signed-request auth
|
|
144
|
+
|
|
145
|
+
When a DVM owns persistent state (feeds, accounts, subscriptions) and must verify the caller owns
|
|
146
|
+
the resource being acted on, require **signed requests**. Signatures are **secp256k1 with BIP-340
|
|
147
|
+
Schnorr** over a versioned statement containing the exact input plus the attested DVM audience, HTTP method, concrete path, and capability, with timestamp-drift and replay protection. A proof for one DVM or operation cannot be replayed against another. This is a protocol invariant — caller auth and builder identity are always
|
|
148
|
+
secp256k1+Schnorr via `@noble/curves`, never Ed25519 or another curve. Don't introduce other
|
|
149
|
+
curves into auth/identity paths.
|
|
150
|
+
|
|
151
|
+
The schema must include the envelope fields `pubkey`, `signature`, `timestamp`, `nonce` alongside the operation's payload fields. `auth_statement` is protocol-owned and the SDK removes it before builder schema parsing:
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
import { z } from "@dvmkit/sdk";
|
|
155
|
+
|
|
156
|
+
const addEpisodeSchema = z.object({
|
|
157
|
+
// payload
|
|
158
|
+
feed_id: z.string(),
|
|
159
|
+
audio_url: z.string().url(),
|
|
160
|
+
// envelope (the SDK verifies these)
|
|
161
|
+
pubkey: z.string(),
|
|
162
|
+
signature: z.string(),
|
|
163
|
+
timestamp: z.number().int(),
|
|
164
|
+
nonce: z.string(),
|
|
165
|
+
});
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
**What gets verified is the raw wire body**, on `/v1/quote`, on the `/v1/job` submit and on the
|
|
169
|
+
cross-machine reactivation re-verify alike (`signedRequestInput`, — never the
|
|
170
|
+
Zod-parsed input. So your schema is free to fill defaults at any depth, coerce, or transform: the
|
|
171
|
+
parse shapes what the handler receives, not what the signature covers. The one thing that must
|
|
172
|
+
match is the wire body itself, which is why a client signs the payload it is about to send rather
|
|
173
|
+
than a normalised copy of it.
|
|
174
|
+
|
|
175
|
+
#### Canonical pattern — `auth: secp256k1Auth({...})` at descriptor level
|
|
176
|
+
|
|
177
|
+
For most stateful DVMs, declare auth on the descriptor (shown in the cast example above). The SDK
|
|
178
|
+
then publishes `secp256k1-schnorr-v2` plus its attested `auth_audience` on `/v1/info`, and rejects unsigned / stale / replayed or wrong-domain envelopes with HTTP 401 **before payment is taken or any
|
|
179
|
+
handler runs**, and records the replay nonce only after upfront payment verifies,
|
|
180
|
+
. Inside the handler, the verified caller identity is on `ctx.auth`:
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
import { secp256k1Auth } from "@dvmkit/sdk/server";
|
|
184
|
+
|
|
185
|
+
// auth: secp256k1Auth({
|
|
186
|
+
// replayStore: runtime.replays, // omit for the bounded in-memory FIFO default
|
|
187
|
+
// driftSeconds: 300, // ±5 min clock drift (default)
|
|
188
|
+
// }),
|
|
189
|
+
|
|
190
|
+
async onJob(ctx) {
|
|
191
|
+
// The envelope is already verified. ctx.auth.pubkey is the trusted caller key;
|
|
192
|
+
// ctx.auth.envelope holds the parsed payload, so no re-parsing is needed.
|
|
193
|
+
const pubkey = ctx.auth!.pubkey;
|
|
194
|
+
const { feed_id } = ctx.auth!.envelope as { feed_id: string };
|
|
195
|
+
|
|
196
|
+
if (!(await runtime.db.ownsFeed(pubkey, feed_id))) {
|
|
197
|
+
ctx.fail("auth_error: caller does not own this feed");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
// ...
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
`replayStore` accepts a `PostgresReplayStore` (cross-instance, also from `@dvmkit/sdk/server`);
|
|
205
|
+
omit it for a bounded in-memory FIFO default suitable for single-instance DVMs.
|
|
206
|
+
|
|
207
|
+
#### Lower-level primitive — `createSignedRequestVerifier`
|
|
208
|
+
|
|
209
|
+
When you need hands-on control on a custom route, use the verifier directly. The SDK passes the resolved audience to `routes(app, context)`; combine it with the live request method and URL so mount prefixes remain covered. The descriptor-level `secp256k1Auth` is built on the same primitive.
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
import { createSignedRequestVerifier, SignedRequestError } from "@dvmkit/sdk/server";
|
|
213
|
+
|
|
214
|
+
const verifier = createSignedRequestVerifier(addEpisodeSchema, {
|
|
215
|
+
driftSeconds: 300, // ±5 min clock drift (default)
|
|
216
|
+
replayStore: replays, // pass null to disable replay protection
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
routes(app, { authAudience }) {
|
|
220
|
+
if (!authAudience) throw new Error("signed route requires descriptor auth");
|
|
221
|
+
app.post("/private/feed", async (c) => {
|
|
222
|
+
try {
|
|
223
|
+
// Two-step: `checkAuth` is sync — schema → drift → signature.
|
|
224
|
+
// `recordReplay` is async — commits the nonce, throws on duplicate. Split
|
|
225
|
+
// so validation does not burn a nonce before the operation is accepted.
|
|
226
|
+
const signed = verifier.checkAuth(await c.req.json(), {
|
|
227
|
+
audience: authAudience,
|
|
228
|
+
method: c.req.method,
|
|
229
|
+
path: new URL(c.req.url).pathname,
|
|
230
|
+
capability: null,
|
|
231
|
+
});
|
|
232
|
+
await verifier.recordReplay(signed);
|
|
233
|
+
// signed.pubkey is now trusted — use it for ownership checks
|
|
234
|
+
if (!(await runtime.db.ownsFeed(signed.pubkey, signed.feed_id))) {
|
|
235
|
+
return c.json({ error: "auth_error" }, 401);
|
|
236
|
+
}
|
|
237
|
+
// ...
|
|
238
|
+
} catch (err) {
|
|
239
|
+
if (err instanceof SignedRequestError) {
|
|
240
|
+
return c.json({ error: "auth_error", sub_reason: err.sub_reason }, 401);
|
|
241
|
+
}
|
|
242
|
+
throw err;
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
`verifier.checkAuth(input, domain)` returns the parsed input typed as `T & CanonicalEnvelope` on success
|
|
249
|
+
and throws `SignedRequestError` (`sub_reason`: `schema_invalid` | `timestamp_drift` |
|
|
250
|
+
`signature_invalid`). `verifier.recordReplay(envelope)` commits the nonce and throws
|
|
251
|
+
`SignedRequestError` (`replay_detected`) on a duplicate. Pair them with your own error taxonomy
|
|
252
|
+
for a domain-specific error code (cast wraps both as `auth_error`).
|
|
253
|
+
|
|
254
|
+
In `onQuote` (and the 402-discovery hop), call `checkAuth` only — skip `recordReplay`. The caller creates a separate `/v1/job` proof because method/path/capability are signed; quote and submit never reuse a proof.
|
|
255
|
+
|
|
256
|
+
`verifier.signRequest(privkey, body, opts?, domain)` is the matching client-side primitive — useful for
|
|
257
|
+
round-trip tests that exercise the real crypto path:
|
|
258
|
+
|
|
259
|
+
```typescript
|
|
260
|
+
import { schnorr } from "@noble/curves/secp256k1.js";
|
|
261
|
+
|
|
262
|
+
const { secretKey } = schnorr.keygen();
|
|
263
|
+
const signed = verifier.signRequest(secretKey, {
|
|
264
|
+
feed_id: "morning-news",
|
|
265
|
+
audio_url: "https://example.com/episode-1.mp3",
|
|
266
|
+
}, undefined, {
|
|
267
|
+
audience: authAudience,
|
|
268
|
+
method: "POST",
|
|
269
|
+
path: "/private/feed",
|
|
270
|
+
capability: null,
|
|
271
|
+
});
|
|
272
|
+
// signed.pubkey, signed.signature, signed.timestamp, signed.nonce are filled in.
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### Refund-on-failure policy
|
|
276
|
+
|
|
277
|
+
A failed job never debits — the draw is released, on every rail, with nothing to wire (see
|
|
278
|
+
[Prepaid credit](pricing-credit.md#prepaid-credit)). `{ refund: true }` is a separate annotation recording that the failure
|
|
279
|
+
was the caller's fault; the first-party DVMs all key it off an `OPERATOR_FAULT_CODES` set:
|
|
280
|
+
|
|
281
|
+
```typescript
|
|
282
|
+
const refund = !OPERATOR_FAULT_CODES.has(err.code) ? { refund: true } : undefined;
|
|
283
|
+
ctx.fail(`[${err.code}] ${err.hint}`, refund);
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
Keep annotating — but don't rely on it moving money. Its one runtime branch (hand back a Cashu change token
|
|
287
|
+
from proofs still held in-process) never fires under the P2PK-accumulator path, and no rail reverses a
|
|
288
|
+
settled payment. What decides whether the caller can _reach_ the released value is your **`auth` slot**, not your
|
|
289
|
+
`credit` block — the draw path never reads `credit`, it needs a verified pubkey. Without `auth` every caller is
|
|
290
|
+
the pooled `anonymous` requester and an explicit draw is refused, so the money stays with you in practice. With
|
|
291
|
+
`auth`, the release lands on a credit keyed to their pubkey and the failed job's receipt carries its `credit_id`,
|
|
292
|
+
so the protocol lets them spend it down on the next job with nothing extra wired (the `dvm` CLI doesn't do that
|
|
293
|
+
automatically yet. Offering credit is the further step
|
|
294
|
+
that makes it _reclaimable as money_ — that's what `drain` and `POST /v1/credit` are gated on.
|
|
295
|
+
|
|
296
|
+
### Signed receipts
|
|
297
|
+
|
|
298
|
+
Every job that reaches a terminal status — `completed`, `failed`, `cancelled` — carries a signed
|
|
299
|
+
`receipt` block on its response. **Nothing to wire:** `dvmctl deploy` derives the key
|
|
300
|
+
from the builder identity only after the platform returns the immutable DVM ID, binds both into the
|
|
301
|
+
attestation, and provisions it; the SDK signs at the terminal.
|
|
302
|
+
|
|
303
|
+
```jsonc
|
|
304
|
+
"receipt": {
|
|
305
|
+
"v": 1, "job_id": "job-a7571b-1", "dvm_id": "<immutable-id>", "dvm": "<slug>", "capability": "count",
|
|
306
|
+
"outcome": "completed", // completed | failed | cancelled
|
|
307
|
+
"reason": null, // terminal reason on failed/cancelled
|
|
308
|
+
"seq": 4213, // per-DVM monotonic terminal counter
|
|
309
|
+
"issued_at": 1789300000,
|
|
310
|
+
"requester_pubkey": null, // set when signed-request auth was used
|
|
311
|
+
"paid": { "msats": 19000, "rail": "cashu", "native_amount": null,
|
|
312
|
+
"native_asset": null, "tx_hash": null, "mint": "https://..." },
|
|
313
|
+
"result_hash": "<sha256 hex>", // sha256(canonicalize({summary, artifact_hashes})); summary
|
|
314
|
+
// only on completed, null otherwise; null if nothing delivered
|
|
315
|
+
"receipt_pubkey": "<64-hex>",
|
|
316
|
+
"signature": "<128-hex>", // BIP-340 over canonicaliseForSigning(receipt minus signature)
|
|
317
|
+
"credit": { // present when the payment funded/drew the credit ledger
|
|
318
|
+
"credit_id": "imp:cashu:…", "draw_id": "…", "amount": 10000,
|
|
319
|
+
"balance_after": 240000, // settled draw: the recorded trajectory.
|
|
320
|
+
// released draw (failed/cancelled): restored available balance
|
|
321
|
+
"ledger_seq": 7 // per-credit monotonic draw counter
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
What a builder needs to know:
|
|
327
|
+
|
|
328
|
+
- Same receipt on the synchronous 200, the poll, and every later re-read — signed once at the
|
|
329
|
+
terminal and persisted, not re-signed per response (JSON key order can differ; canonicalisation
|
|
330
|
+
is what the signature covers).
|
|
331
|
+
- Free jobs get receipts too (`paid.msats: 0`); they consume a `seq` like any other job.
|
|
332
|
+
- `/v1/info` advertises `receipts: true` only when a key is wired **and** the job store can issue
|
|
333
|
+
(`claimReceiptSeq` / `saveReceipt` — both built-in stores do), so absence is detectable. A custom
|
|
334
|
+
`jobStore` that can't warns `receipts_disabled_store_unsupported` at boot.
|
|
335
|
+
- Signing failure never breaks a job: the SDK logs `receipt_issue_failed` and serves without one.
|
|
336
|
+
- `dvmctl dev` / `devMode` boots mint an ephemeral key — receipts verify locally but nothing
|
|
337
|
+
attests them. Outside dev, no key means no receipts and no flag.
|
|
338
|
+
- Isolate-runtime DVMs don't issue receipts yet.
|
|
339
|
+
|
|
340
|
+
Verifying one (four links, all required): the signature verifies under `receipt_pubkey`; the receipt's
|
|
341
|
+
immutable `dvm_id` equals `/v1/info#builder`'s `attestation.dvm_id`; `receipt_pubkey` equals that
|
|
342
|
+
attestation's `receipt_pubkey`; and the attestation verifies under `builder.pubkey`. The slug stays
|
|
343
|
+
signed display metadata, never the trust identity. Use the published caller command
|
|
344
|
+
`dvm receipts verify <job-id>` to check a stored receipt rather than reimplementing its canonicalisation.
|
|
345
|
+
|
|
346
|
+
### Custom HTTP routes
|
|
347
|
+
|
|
348
|
+
Two seams:
|
|
349
|
+
|
|
350
|
+
- `routes(app)` on the descriptor — routes that **belong to this DVM**. They register on the DVM's
|
|
351
|
+
Hono sub-app and inherit any mount prefix.
|
|
352
|
+
- `host.app` on the host — routes that **belong to the host** (cross-DVM `/health`, cron-callable
|
|
353
|
+
maintenance, anything not tied to one DVM).
|
|
354
|
+
|
|
355
|
+
DVM-scoped (cast feed XML):
|
|
356
|
+
|
|
357
|
+
```typescript
|
|
358
|
+
configureDVM({
|
|
359
|
+
name: "cast",
|
|
360
|
+
capabilities: {
|
|
361
|
+
/* ... */
|
|
362
|
+
},
|
|
363
|
+
|
|
364
|
+
routes(app) {
|
|
365
|
+
app.get("/feeds/:owner/:slug", async (c) => {
|
|
366
|
+
const xml = await renderFeed(c.req.param("owner"), c.req.param("slug"));
|
|
367
|
+
return c.body(xml, 200, { "Content-Type": "application/rss+xml" });
|
|
368
|
+
});
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Host-scoped (scrape's pool-aware `/health`, registered before `host.mount`):
|
|
374
|
+
|
|
375
|
+
```typescript
|
|
376
|
+
import { createDVMHost } from "@dvmkit/sdk/server";
|
|
377
|
+
|
|
378
|
+
const host = createDVMHost();
|
|
379
|
+
|
|
380
|
+
let shuttingDown = false;
|
|
381
|
+
host.app.get("/health", (c) => {
|
|
382
|
+
if (shuttingDown) return c.json({ ok: false, draining: true }, 503);
|
|
383
|
+
return c.json({ ok: true, pool: runtime.pool.health() });
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
host.mount(createScrapeDVM(runtime));
|
|
387
|
+
await host.serve();
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
---
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Payment rail limits for first builds
|
|
2
|
+
|
|
3
|
+
Use Cashu FakeWallet for the local paid demonstration. It is the only supported builder-facing local payment path in the admitted SDK release.
|
|
4
|
+
|
|
5
|
+
| Rail | First-build local proof | Current limit |
|
|
6
|
+
| --- | --- | --- |
|
|
7
|
+
| Cashu | Supported with a local Nutshell FakeWallet mint, disposable Postgres and builder/caller state. | Test value only; never deploy the test mint or key. Follow [Local Cashu paid test](local-cashu-test.md). |
|
|
8
|
+
| x402 | No builder-facing x402-only local verification. | Do not configure a mainnet endpoint as a substitute for a test. |
|
|
9
|
+
| Tempo | No supported builder-facing local test recipe. | The forthcoming Tempo testnet environment switches are not in the admitted SDK release. |
|
|
10
|
+
|
|
11
|
+
Payment configuration for a deployed DVM belongs to the deployment decision. A successful Cashu test proves the local Cashu path only; it does not validate another rail, production credentials, payout, or real-money settlement.
|