@nivaro/sdk 0.1.16 → 0.1.18

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/README.md CHANGED
@@ -24,6 +24,14 @@ The `@nivaro/sdk` package is a fully-typed TypeScript client for the Nivaro REST
24
24
  pnpm add @nivaro/sdk
25
25
  ```
26
26
 
27
+ #### MCP server (Claude Code, Claude Desktop, Cursor)
28
+
29
+ The companion package `@nivaro/mcp` wraps the SDK as a Model Context Protocol server, so an AI coding assistant can read and write an instance directly: collection schemas, record reads and aggregates, rehearsed creates, updates, deletes with an explicit confirm, pipeline state and transitions, and the instance's own ask-your-data assistant. It authenticates with an `nvk_` API key, so role permissions, row-level security and user scopes apply unchanged; a sandbox key is read-only by construction. Install and client config snippets live in the package README (`packages/mcp/README.md`).
30
+
31
+ ```typescript
32
+ claude mcp add nivaro -e NIVARO_URL=https://nivaro.example.com -e NIVARO_TOKEN=nvk_... -- npx -y @nivaro/mcp
33
+ ```
34
+
27
35
  #### Create a client
28
36
 
29
37
  ```typescript
@@ -2446,6 +2454,91 @@ await nivaro.request(createHierarchyConfig({
2446
2454
 
2447
2455
  ---
2448
2456
 
2457
+ ## SDK — Help Videos
2458
+
2459
+ Tutorial recordings: upload, edit, version, publish, choose where they show, require viewing and track progress. Authoring commands need an author (an administrator or a role in the author setting); reading and progress need only visibility.
2460
+
2461
+ ```typescript
2462
+ import {
2463
+ openHelpVideoUpload, finalizeHelpVideoUpload, createHelpVideo,
2464
+ setHelpVideoContexts, publishHelpVideo, helpVideosFor,
2465
+ } from '@nivaro/sdk'
2466
+
2467
+ // 1. Open an upload, send the bytes, finalize, create the video
2468
+ const { data: up } = await nivaro.request(openHelpVideoUpload('video/webm'))
2469
+ let part = up.next_part
2470
+ for (const chunk of chunks) {
2471
+ // Raw PUT — not a Command, because Commands JSON-encode their bodies.
2472
+ await fetch(`${baseUrl}/api/help-videos/uploads/${up.id}/parts/${part++}`, {
2473
+ method: 'PUT',
2474
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/octet-stream' },
2475
+ body: chunk, // at most 8 MB per part; the response is the upload session
2476
+ })
2477
+ }
2478
+ await nivaro.request(finalizeHelpVideoUpload(up.id, { duration_ms }))
2479
+ const { data: video } = await nivaro.request(createHelpVideo({ upload_id: up.id, title: 'Raise a PO' }))
2480
+
2481
+ // 2. Say where it shows, then publish
2482
+ await nivaro.request(setHelpVideoContexts(video.id, [{ kind: 'page', key: 'inbox', state_key: null }]))
2483
+ await nivaro.request(publishHelpVideo(video.id))
2484
+
2485
+ // 3. Show the right videos on a screen
2486
+ const { data } = await nivaro.request(helpVideosFor({ collection: 'purchase_orders', item: 42 }))
2487
+ ```
2488
+
2489
+ #### Response notes
2490
+
2491
+ - `stream_url`, `captions_url` and `poster_url` are ticketed (`?st=`): hand them to `<video>`, `<track>` and `<img>` as-is. Authors also get `draft_stream_url` and `draft_captions_url`.
2492
+ - `published.playable` is `false` when a viewer’s stream would answer 409 (the video is still being prepared); authors always get `true`.
2493
+ - `required` is true when the video is required for the caller’s own role. Authors also get `required_role_ids`. `my_progress` is the caller’s own progress (or `null`).
2494
+ - `recordHelpVideoProgress` takes `buckets` as a 20-character `0`/`1` string (the 5% sections seen) and answers `{ data: { completed } }`, or no body (204) for a masquerade session.
2495
+ - `listHelpVideos` and `helpVideosFor` carry `can_author`, so a screen can offer "Add a video" without a second call.
2496
+
2497
+ #### Errors callers must handle
2498
+
2499
+ | Status | code | When |
2500
+ | --- | --- | --- |
2501
+ | 409 | `HELP_VIDEO_PROCESSING` | A viewer requested `stream_url` before a current render exists and the original cannot be shown. Check `published.playable`, retry later. |
2502
+ | 409 | `HELP_VIDEO_EDITS_CONFLICT` | `saveHelpVideoDraft` with a stale `base_hash`. The body carries `current_hash`; reload the draft and merge. |
2503
+ | 409 | `UPLOAD_CLOSED` | The upload is already finalized or finishing; or, on discard, already used by a video or discarded. |
2504
+ | 422 | `UPLOAD_NOT_VIDEO` | A part is not a WebM or MP4 recording. |
2505
+ | 422 | `UPLOAD_TOO_LONG` | The recording is longer than 30 minutes (on finalize). |
2506
+ | 422 | `HELP_VIDEO_NOT_READY` | `publishHelpVideo` before the checklist is done; the body lists `missing`. |
2507
+ | 409 | `HELP_VIDEO_NOTHING_TO_PUBLISH` | `publishHelpVideo` with no draft, or a draft that is exactly the published version (`draft_matches_published`). With `watch_again` on a video someone must watch, it re-asks them instead. |
2508
+ | 422 | `HELP_VIDEO_EDITS_INVALID` | `saveHelpVideoDraft` with edits that cannot be kept, such as cuts that leave less than a second. The message says why; the stored draft is unchanged. |
2509
+ | 409 | `HELP_VIDEO_NOTHING_TO_RENDER` | `rerenderHelpVideo` for a version that does not exist. |
2510
+ | 403 | `HELP_VIDEO_AUTHOR_ONLY` | An authoring command by a non-author. |
2511
+ | 404 | `HELP_VIDEO_NOT_FOUND` | Unknown id, or a video the caller may not see. |
2512
+
2513
+ | Command | Route | Auth |
2514
+ | --- | --- | --- |
2515
+ | listHelpVideos(params?) | GET /help-videos | Authenticated |
2516
+ | readHelpVideo(id) | GET /help-videos/:id | Authenticated |
2517
+ | helpVideosFor(params) | GET /help-videos/for | Authenticated |
2518
+ | createHelpVideo(body) | POST /help-videos | Author |
2519
+ | updateHelpVideo(id, body) | PATCH /help-videos/:id | Author |
2520
+ | setHelpVideoContexts(id, contexts) | PUT /help-videos/:id/contexts | Author |
2521
+ | setHelpVideoRequirements(id, role_ids) | PUT /help-videos/:id/requirements | Author |
2522
+ | archiveHelpVideo(id, opts?) | DELETE /help-videos/:id | Author (purge: admin, archived videos only) |
2523
+ | readHelpVideoDraft(id) | GET /help-videos/:id/draft/edits | Author |
2524
+ | saveHelpVideoDraft(id, edits, base_hash?) | PUT /help-videos/:id/draft/edits | Author |
2525
+ | publishHelpVideo(id, opts?) | POST /help-videos/:id/publish | Author |
2526
+ | rerecordHelpVideo(id, upload_id) | POST /help-videos/:id/rerecord | Author |
2527
+ | listHelpVideoVersions(id) | GET /help-videos/:id/versions | Author |
2528
+ | restoreHelpVideoVersion(id, versionId) | POST /help-videos/:id/versions/:vid/restore | Author |
2529
+ | rerenderHelpVideo(id, opts?) | POST /help-videos/:id/render | Author |
2530
+ | recordHelpVideoProgress(id, body) | POST /help-videos/:id/progress | Authenticated |
2531
+ | readRequiredHelpVideos() | GET /help-videos/required/mine | Authenticated |
2532
+ | readHelpVideoAnalytics(id) | GET /help-videos/:id/analytics | Author |
2533
+ | openHelpVideoUpload(mime) | POST /help-videos/uploads | Author |
2534
+ | finalizeHelpVideoUpload(id, meta?) | POST /help-videos/uploads/:id/finalize | Author |
2535
+ | listMyHelpVideoUploads() | GET /help-videos/uploads/mine | Author |
2536
+ | abandonHelpVideoUpload(id) | DELETE /help-videos/uploads/:id | Author |
2537
+ | listHelpVideoPages() | GET /help-videos/pages | Authenticated |
2538
+ | registerHelpVideoPage(body) | POST /help-videos/pages | Author |
2539
+
2540
+ ---
2541
+
2449
2542
  ## SDK Coverage: 300+ Typed Commands
2450
2543
 
2451
2544
  The @nivaro/sdk command surface now covers every feature area — roughly 175 typed `Command<T>` factories spanning items, files, workflows, pipelines, flows, comments, webhooks, rules, custom queries, trees and hierarchies, submission forms, field watches, notification subscriptions, imports, SLA, alerts, AI endpoints (generate, summarize, validate, check-duplicates), translations, drafts, scheduled changes, record templates, saved views, API keys, widget feeds, sync jobs, ERP submissions, PDF templates, pages, queues (worklists, items, claims, saved views, trends, workload), roles & policies (RBAC/RLS), user management + out-of-office delegation, file management (list/meta/presign/transform URLs), dashboard widgets, extension item/bulk actions, throughput reporting, and more. If a REST route exists, there is a typed command for it.
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Extension scaffolder (#1302) — `nivaro ext init <id> [--dir api/extensions]`.
3
+ *
4
+ * Writes `<dir>/<id>/` with an index.ts written against @nivaro/extension-kit
5
+ * (one setting, one hook, one described cron, one route behind requireAuth),
6
+ * a passing test on the kit's createTestContext, and a README naming the
7
+ * context members the starter uses. Refuses an existing directory and an id
8
+ * that is not kebab-case.
9
+ *
10
+ * Plain ESM so the CLI imports it directly and `node --test` covers it.
11
+ */
12
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
13
+ import { isAbsolute, join, relative, resolve } from 'node:path'
14
+
15
+ /** kebab-case: lowercase words joined by single hyphens, 2–64 characters. */
16
+ const ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
17
+
18
+ /** Null when the id is usable, otherwise the reason it is not. */
19
+ export function validateExtensionId(id) {
20
+ if (typeof id !== 'string' || id.length === 0) return 'An extension id is required'
21
+ if (id.length < 2 || id.length > 64) return 'The id must be 2–64 characters'
22
+ if (!ID.test(id))
23
+ return `"${id}" is not kebab-case — use lowercase letters, digits and single hyphens (my-extension)`
24
+ return null
25
+ }
26
+
27
+ /** The directory the extension goes in when --dir is not given. */
28
+ export function defaultExtensionsDir(cwd = process.cwd()) {
29
+ if (existsSync(join(cwd, 'api', 'extensions'))) return join(cwd, 'api', 'extensions')
30
+ if (existsSync(join(cwd, 'extensions', 'tsconfig.json'))) return join(cwd, 'extensions')
31
+ return join(cwd, 'api', 'extensions')
32
+ }
33
+
34
+ function camel(id) {
35
+ return id.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase())
36
+ }
37
+
38
+ /** The files, by name, for an extension called `id`. */
39
+ export function scaffoldFiles(id) {
40
+ // The id is written into source text and a path: only a validated one.
41
+ const bad = validateExtensionId(id)
42
+ if (bad) throw new Error(bad)
43
+ const label = id
44
+ .split('-')
45
+ .map((w) => w[0].toUpperCase() + w.slice(1))
46
+ .join(' ')
47
+ const counter = `${camel(id)}Creates`
48
+
49
+ const index = `import { defineExtension } from '@nivaro/extension-kit'
50
+
51
+ /**
52
+ * ${label} — scaffolded by \`nivaro ext init ${id}\`.
53
+ *
54
+ * One of each thing an extension usually starts with: a setting an admin edits
55
+ * on the Extensions page, a hook on record writes, a scheduled job, and a route.
56
+ * The Extensions registry sheet lists each of them once the API loads this file.
57
+ */
58
+
59
+ /** Creates seen since the last daily summary, per collection. In memory: a
60
+ * restart starts the count again, which is fine for a summary line. */
61
+ const ${counter} = new Map<string, number>()
62
+
63
+ export default defineExtension({
64
+ id: '${id}',
65
+ settings: [
66
+ {
67
+ key: 'greeting',
68
+ label: 'Greeting',
69
+ type: 'string',
70
+ default: 'Hello',
71
+ description: 'The word GET /api/${id}/hello answers with.'
72
+ }
73
+ ],
74
+ async register(ctx) {
75
+ const { app, auth, hooks, cron, logger } = ctx
76
+
77
+ // Route. \`preHandler: auth.requireAuth\` is the gate: the loader records it,
78
+ // and a route with no gate is a warning on the readiness scorecard.
79
+ app.register(
80
+ async (f) => {
81
+ f.get('/${id}/hello', { preHandler: auth.requireAuth }, async () => {
82
+ const greeting = (await ctx.settings?.get('greeting')) ?? 'Hello'
83
+ return { data: { message: \`\${greeting} from ${id}\` } }
84
+ })
85
+ },
86
+ { prefix: '/api' }
87
+ )
88
+
89
+ // Hook. After-hooks run once the write has landed; keep them quick, the
90
+ // caller waits for them.
91
+ hooks.after('*', 'create', async ({ collection }) => {
92
+ ${counter}.set(collection, (${counter}.get(collection) ?? 0) + 1)
93
+ })
94
+
95
+ // Scheduled job. The id is scoped to this extension; the description is
96
+ // what Background Jobs shows beside it.
97
+ cron.schedule(
98
+ 'daily-summary',
99
+ '0 6 * * *',
100
+ async () => {
101
+ const total = [...${counter}.values()].reduce((n, v) => n + v, 0)
102
+ const busiest = [...${counter}.entries()].sort((a, b) => b[1] - a[1])[0]
103
+ ${counter}.clear()
104
+ await ctx.logActivity({
105
+ action: 'daily-summary',
106
+ comment: busiest
107
+ ? \`\${total} record(s) created; most in \${busiest[0]} (\${busiest[1]})\`
108
+ : 'No records created'
109
+ })
110
+ logger.info({ total }, '${id}: daily summary written')
111
+ },
112
+ {
113
+ description: 'One activity line a day counting the records created since the last.',
114
+ idempotent: 'safe'
115
+ }
116
+ )
117
+ }
118
+ })
119
+ `
120
+
121
+ const test = `import { createTestContext } from '@nivaro/extension-kit'
122
+ import { describe, expect, it } from 'vitest'
123
+ import extension from './index.js'
124
+
125
+ describe('${id}', () => {
126
+ it('answers the hello route with the configured greeting', async () => {
127
+ const ctx = createTestContext({ settings: { greeting: 'Hi' } })
128
+ await extension.register(ctx)
129
+ const res = await ctx.invoke('GET', '/api/${id}/hello')
130
+ expect(res).toEqual({ status: 200, body: { data: { message: 'Hi from ${id}' } } })
131
+ })
132
+
133
+ it('counts creates and reports them from the daily job', async () => {
134
+ const ctx = createTestContext()
135
+ await extension.register(ctx)
136
+ await ctx.runHooks('orders', 'create', 'after', { keys: [1] })
137
+ await ctx.runHooks('orders', 'create', 'after', { keys: [2] })
138
+ await ctx.runCron('daily-summary')
139
+ expect(ctx.calls.activity).toEqual([
140
+ { action: 'daily-summary', comment: '2 record(s) created; most in orders (2)' }
141
+ ])
142
+ expect(ctx.registered.crons.get('daily-summary')?.opts?.description).toBeTruthy()
143
+ })
144
+
145
+ it('uses no deprecated kit member', async () => {
146
+ const ctx = createTestContext()
147
+ await extension.register(ctx)
148
+ expect(ctx.calls.deprecations).toEqual([])
149
+ })
150
+ })
151
+ `
152
+
153
+ const readme = `# ${label}
154
+
155
+ Scaffolded by \`nivaro ext init ${id}\`. The API loads it from \`api/extensions/${id}/\` at boot
156
+ (restart the API after adding it); the Extensions page lists it with a registry sheet of
157
+ everything it registered.
158
+
159
+ ## What it registers
160
+
161
+ | Thing | Where | Context member |
162
+ |---|---|---|
163
+ | Setting \`greeting\` | Extensions page → ${id} → Settings | \`settings\` on the export, read with \`ctx.settings.get('greeting')\` |
164
+ | Route \`GET /api/${id}/hello\` | behind \`requireAuth\` | \`ctx.app.register\`, gated with \`ctx.auth.requireAuth\` |
165
+ | Hook after every create | all collections | \`ctx.hooks.after('*', 'create', fn)\` |
166
+ | Daily job \`daily-summary\` | Background Jobs | \`ctx.cron.schedule(id, expression, fn, { description })\` |
167
+ | Activity line | Activity log | \`ctx.logActivity({ action, comment })\` |
168
+
169
+ ## Context members you will reach for next
170
+
171
+ - \`ctx.database\` — knex on the instance database (raw writes skip hooks, revisions and permissions; prefer the items API for record writes).
172
+ - \`ctx.notifyUser(userId, { subject, message })\` — the only way to notify a person.
173
+ - \`ctx.callExternalApi(name, { method, path, body })\` — configured external APIs; credentials never reach the extension.
174
+ - \`ctx.flows.registerOperation\` / \`registerTrigger\` / \`emit\` — custom flow steps and triggers.
175
+ - \`ctx.readiness.registerCheck\`, \`ctx.integrity.registerCheck\` — scored checks and Data Integrity checks.
176
+ - \`ctx.events.publish\` / \`on\` — a durable outbox for this extension's own events.
177
+ - \`ctx.sql.runLong(sql)\` — one long statement outside knex's request timeout.
178
+
179
+ The full contract is \`ExtensionContext\` in \`@nivaro/extension-kit\`.
180
+
181
+ ## Test and type-check
182
+
183
+ \`\`\`bash
184
+ cd api
185
+ npx vitest run extensions/${id}
186
+ npx tsc -p extensions/tsconfig.json
187
+ \`\`\`
188
+
189
+ The test runs the extension against the kit's \`createTestContext()\`: every side effect lands in
190
+ \`ctx.calls\`, every registration in \`ctx.registered\`, and \`ctx.calls.deprecations\` lists any
191
+ deprecated kit member the extension read.
192
+ `
193
+
194
+ return { 'index.ts': index, 'index.test.ts': test, 'README.md': readme }
195
+ }
196
+
197
+ /**
198
+ * Writes the extension. Throws (with the reason) on a bad id or an existing
199
+ * directory; answers the paths written.
200
+ */
201
+ export function writeScaffold(id, dir) {
202
+ const bad = validateExtensionId(id)
203
+ if (bad) throw new Error(bad)
204
+ if (typeof dir !== 'string' || dir.length === 0 || dir.includes('\0'))
205
+ throw new Error('--dir must be a directory path')
206
+ const base = resolve(dir)
207
+ const target = join(base, id)
208
+ // The extension lands exactly one level under --dir, never beside or above it.
209
+ const rel = relative(base, target)
210
+ if (rel !== id || isAbsolute(rel)) throw new Error(`Refusing to write outside ${base}`)
211
+ if (existsSync(target)) throw new Error(`${target} already exists — pick another id or remove it`)
212
+ mkdirSync(target, { recursive: true })
213
+ const written = []
214
+ for (const [name, text] of Object.entries(scaffoldFiles(id))) {
215
+ const file = join(target, name)
216
+ writeFileSync(file, text, 'utf8')
217
+ written.push(file)
218
+ }
219
+ return { target, written, hasTsconfig: existsSync(join(base, 'tsconfig.json')) }
220
+ }