@verne-software/sdk 1.3.0 → 1.4.0

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
@@ -5,7 +5,7 @@
5
5
 
6
6
  The official Node.js and TypeScript library for the [Verne Nautilus](https://vernesoft.com) platform.
7
7
 
8
- > **Server-side only.** API keys carry full service access and must never be used in browser or client-side code.
8
+ > **Server-side only.** API keys carry full access to the APIs they were created for and must never be used in browser or client-side code.
9
9
 
10
10
  ## Requirements
11
11
 
@@ -26,21 +26,28 @@ pnpm add @verne-software/sdk
26
26
  ```ts
27
27
  import { Verne } from '@verne-software/sdk';
28
28
 
29
- const verne = new Verne({
30
- relay: process.env.VERNE_RELAY_KEY,
31
- gate: process.env.VERNE_GATE_KEY,
32
- clockwork: process.env.VERNE_CLOCKWORK_KEY,
33
- });
29
+ const verne = new Verne({ apiKey: process.env.VERNE_API_KEY });
30
+
31
+ await verne.relay.messages.send({ event_type: 'user.created', payload: { id: 'usr_123' } });
32
+ await verne.gate.organizations.create({ name: 'Acme' });
34
33
  ```
35
34
 
36
- You can also instantiate services independently if you only need one:
35
+ ### API keys
36
+
37
+ Create a key on **Dashboard → Keys** and tick the APIs it may call — its **scopes**: `relay`, `gate`, `clockwork`, `passepartout`. A key looks like `vrn_9f8a7…` and does not name an API. Pass it once as `apiKey`; the client sends it as `Authorization: Bearer …` to every service.
38
+
39
+ - A call to an API the key has no scope for is refused by the API with `403`, code `forbidden`, message `This API key is not authorized for <API>.` The SDK does not check scopes itself.
40
+ - To use a different key for one service, pass it as `relay`, `gate`, `clockwork` or `passepartout`; it overrides `apiKey` for that service only.
41
+ - There is **no test or live mode and no sandbox**: every key acts on your real data. Use a separate key per environment.
42
+ - Keys made before scopes existed (`vrn_gate_…`, `vrn_relay_…`, …) keep working, for the one API they name.
43
+
44
+ You can also instantiate one service on its own:
37
45
 
38
46
  ```ts
39
- import { Relay, Gate, Clockwork } from '@verne-software/sdk';
47
+ import { Gate, Clockwork } from '@verne-software/sdk';
40
48
 
41
- const relay = new Relay({ apiKey: process.env.VERNE_RELAY_KEY });
42
- const gate = new Gate({ apiKey: process.env.VERNE_GATE_KEY });
43
- const clockwork = new Clockwork({ apiKey: process.env.VERNE_CLOCKWORK_KEY });
49
+ const gate = new Gate({ apiKey: process.env.VERNE_API_KEY });
50
+ const clockwork = new Clockwork({ apiKey: process.env.VERNE_API_KEY });
44
51
  ```
45
52
 
46
53
  ## Relay — Webhooks-as-a-Service
@@ -60,7 +67,7 @@ Optional parameters:
60
67
  await verne.relay.messages.send({
61
68
  event_type: 'order.placed',
62
69
  payload: { order_id: '999' },
63
- idempotency_key: 'evt_abc', // prevent duplicate delivery within 24h
70
+ idempotency_key: 'evt_abc', // a repeat within 12 hours returns the original message
64
71
  channels: ['team-a'], // restrict to specific endpoint channels
65
72
  });
66
73
  ```
@@ -75,106 +82,216 @@ console.log(page.has_more); // boolean
75
82
  console.log(page.next_cursor); // pass to the next call to paginate
76
83
  ```
77
84
 
78
- ## Gate — Auth-as-a-Service
85
+ ## Gate — sign-in and users
79
86
 
80
- ### Identity Management
87
+ ### Identities
81
88
 
82
- Manage your end-users. The `tenant_id` is automatically scoped to your API key.
89
+ Manage your end users. They are kept apart per tenant: the same address at another tenant is a different person.
83
90
 
84
91
  ```ts
85
- // Create a user
92
+ // Create a user (traits are `email` and, optionally, `locale`)
86
93
  const identity = await verne.gate.identities.create({
87
94
  schema_id: 'user',
88
- traits: {
89
- email: 'user@example.com',
90
- custom_data: { role: 'editor' },
91
- },
95
+ traits: { email: 'user@example.com' },
92
96
  credentials: {
93
97
  password: { config: { password: 'StrongPassword123!' } },
94
98
  },
95
- state: 'active',
96
99
  });
100
+ // A second user with the same address in your account is a 409 `conflict`.
97
101
 
98
- // Get a user
102
+ // Get, update (JSON Patch — RFC 6902), delete
99
103
  await verne.gate.identities.get(identity.id);
100
-
101
- // Update a user (JSON Patch — RFC 6902)
102
104
  await verne.gate.identities.patch(identity.id, [
103
- { op: 'replace', path: '/traits/custom_data/role', value: 'admin' },
105
+ { op: 'replace', path: '/traits/email', value: 'new@example.com' },
104
106
  ]);
105
-
106
- // Delete a user
107
107
  await verne.gate.identities.delete(identity.id);
108
108
 
109
- // Activate / deactivate a user (an inactive user cannot log in)
109
+ // Activate / deactivate (an inactive user cannot sign in; nothing is deleted)
110
110
  await verne.gate.identities.deactivate(identity.id);
111
111
  await verne.gate.identities.activate(identity.id);
112
- // …or set the state explicitly:
113
- await verne.gate.identities.setState(identity.id, 'inactive');
114
112
 
115
113
  // Resend the email verification link
116
114
  await verne.gate.identities.resendVerification(identity.id);
117
115
  ```
118
116
 
119
- ### Security Settings
117
+ Your own fields about a user go in **custom metadata**, not in traits:
118
+
119
+ ```ts
120
+ await verne.gate.identities.updateMetadata(identity.id, { plan: 'pro', seats: 5 }); // merged
121
+ await verne.gate.identities.updateMetadata(identity.id, { seats: null }); // removes `seats`
122
+ const metadata = await verne.gate.identities.getMetadata(identity.id); // { plan: 'pro' }
123
+ ```
124
+
125
+ ### Organizations, roles and members
120
126
 
121
- Read or replace the tenant's security settings (passwordless login, TOTP MFA):
127
+ Group your end users into organizations (their teams, workspaces, companies). A **role** is a named set of permissions inside one organization; a **member** holds one role. Everything acts on the project the key belongs to. Gate keeps this data and reports it; it does not enforce it — ask it.
122
128
 
123
129
  ```ts
124
- const security = await verne.gate.settings.getSecurity();
125
- // security.passwordless_enabled, security.mfa_enabled
130
+ const org = await verne.gate.organizations.create({ name: 'Acme' });
126
131
 
127
- // Both fields are required — the update is a full replacement, not a merge.
128
- await verne.gate.settings.updateSecurity({
129
- passwordless_enabled: true,
130
- mfa_enabled: false,
132
+ const admin = await verne.gate.organizations.roles.create(org.id, {
133
+ name: 'admin', // lowercase, digits, _ and -
134
+ permissions: ['invoices:read', 'invoices:write'], // lowercase segments joined by `:`
135
+ });
136
+
137
+ await verne.gate.organizations.members.add(org.id, {
138
+ identity_id: identity.id,
139
+ role_id: admin.id,
140
+ expires_at: '2027-01-31T23:59:59Z', // optional: the membership ends then
131
141
  });
142
+
143
+ // Change the role or the deadline: leave expires_at out to keep it, null to remove it
144
+ await verne.gate.organizations.members.update(org.id, identity.id, { expires_at: null });
145
+
146
+ // Lists are cursor-paged: loop while has_more, passing next_cursor as cursor
147
+ const { data, has_more, next_cursor } = await verne.gate.organizations.members.list(org.id, { limit: 50 });
148
+
149
+ // A user's organizations, with role and permissions in each
150
+ const held = await verne.gate.identities.listOrganizations(identity.id);
132
151
  ```
133
152
 
134
- ### Access Tokens
153
+ A role somebody holds cannot be deleted (`409 conflict`); a second role of the same name in one organization is `409 conflict`; past 100 roles, `409 limit_reached`.
135
154
 
136
- Exchange your long-lived API key for a short-lived access token:
155
+ #### Checking a permission
137
156
 
138
157
  ```ts
139
- const token = await verne.gate.tokens.create({
140
- subject: 'usr_123',
141
- scopes: ['gate.tokens.read'], // optional
142
- ttl_seconds: 3600, // optional, default 3600, max 86400
158
+ if (!(await verne.gate.organizations.can(org.id, identity.id, 'invoices:write'))) {
159
+ throw new Error('Forbidden');
160
+ }
161
+
162
+ // Or the full decision:
163
+ const decision = await verne.gate.organizations.authorize(org.id, identity.id, 'invoices:write');
164
+ // { allowed: true, decision_id: 'dec_…', reason: 'member_with_permission', role: 'admin' }
165
+ ```
166
+
167
+ `reason` is one of `member_with_permission`, `member_without_permission`, `not_a_member`, `subject_inactive`, `no_such_organization`; `role` is present when the user is a member. The match is exact (no wildcards) and live. A permission no role could hold (`Invoices:*`, the `nautilus:` prefix) is a `400 invalid_payload` error, never `false`.
168
+
169
+ `gate.authorize({ subject, action, resource })` is the same endpoint; with any resource that is not `organization:<id>` it only says whether `subject` is one of your active end users. Its `reason` is a code too: `owned_by_tenant` (the one `allowed: true`), `subject_inactive`, `not_owned_by_tenant`, `not_a_known_identity`. The sentence a person would read is in `message`, for your logs — its wording can change, `reason` is the contract.
170
+
171
+ ```ts
172
+ const decision = await verne.gate.authorize({ subject: identity.id, action: 'read', resource: 'doc:1' });
173
+ // { allowed: true, decision_id: 'dec_…', reason: 'owned_by_tenant', message: 'subject `…` is an identity owned by tenant `…`' }
174
+ ```
175
+
176
+ ### Sessions
177
+
178
+ ```ts
179
+ // Is this session good enough to act on? (the one call to gate your routes on)
180
+ const check = await verne.gate.sessions.check(sessionToken);
181
+ // { active, aal, mfa_required, mfa_satisfied, identity_id, orgs: [{ id, role, permissions }] }
182
+
183
+ const sessions = await verne.gate.sessions.list(identity.id);
184
+ await verne.gate.sessions.revoke(sessions[0].id); // one device
185
+ await verne.gate.sessions.revokeAll(identity.id); // everywhere
186
+ ```
187
+
188
+ `revokeAll()` succeeds also when the user has no session, so a "log out everywhere" button can be pressed twice. An id that is not one of your users (another account's, unknown, or one you deleted) is `403 forbidden`.
189
+
190
+ With MFA required (`settings.updateSecurity({ passwordless_enabled, mfa_enabled: true })`), `check()` throws a `VerneAPIError` with code `mfa_required` and `details.required_aal === 'aal2'`; start a step-up flow with `gate.createLoginFlow({ aal: 'aal2', session_token: sessionToken })`. A dead, foreign or unknown session is `401`.
191
+
192
+ ### Signed tokens (JWT)
193
+
194
+ For a platform that verifies signatures instead of calling Gate (Supabase, an API gateway):
195
+
196
+ ```ts
197
+ const { token } = await verne.gate.tokens.createSigned(sessionToken); // no API key involved
198
+
199
+ const claims = await verne.gate.tokens.verifySigned(token, { tenantId: process.env.VERNE_TENANT_ID! });
200
+ claims.sub; // the end user's id
201
+ claims.orgs; // [{ id, role, permissions }], as of when the token was made
202
+ ```
203
+
204
+ Verification is offline, against your tenant's public keys (fetched once, cached, refetched when a new key appears). It refuses anything but ES256, another tenant's issuer, a bad signature, an expired token — with a `VerneTokenError` whose `reason` says which. To verify where you have no Gate client:
205
+
206
+ ```ts
207
+ import { GateTokenVerifier } from '@verne-software/sdk';
208
+
209
+ const verifier = new GateTokenVerifier({ tenantId: process.env.VERNE_TENANT_ID! });
210
+ const claims = await verifier.verify(token);
211
+ ```
212
+
213
+ A signed token lives **15 minutes** and **cannot be revoked**; its `orgs` are as old as the token. For anything that must follow a revocation or a role change at once, use `sessions.check()`, introspection or `organizations.can()`.
214
+
215
+ ### Audit log
216
+
217
+ ```ts
218
+ const page = await verne.gate.auditLog.list({
219
+ action: 'organization.member_added',
220
+ from: new Date(Date.now() - 7 * 24 * 3600 * 1000),
221
+ limit: 50,
143
222
  });
223
+ // newest first; page.data[i] = { id, occurred_at, action, actor, target, project_id, details, ip, user_agent }
224
+ ```
225
+
226
+ Any key with the `gate` scope reads the **whole** log, including your team members' IP addresses and user agents. Keep `gate` keys on your backend.
227
+
228
+ ### Automatic deletion of inactive accounts
144
229
 
145
- // token.access_token — attach to downstream requests
146
- // token.expires_at — ISO 8601 expiry
230
+ ```ts
231
+ const { tier, rules } = await verne.gate.lifecycle.listRules();
232
+
233
+ const preview = await verne.gate.lifecycle.previewRule('delete_unverified', {
234
+ delete_after_days: 14,
235
+ warn_before_days: 3,
236
+ }); // { matching_now, capped, … } — look before you switch it on
237
+
238
+ await verne.gate.lifecycle.setRule('delete_unverified', {
239
+ enabled: true,
240
+ params: { delete_after_days: 14, warn_before_days: 3 },
241
+ });
242
+
243
+ const actions = await verne.gate.lifecycle.listActions({ status: 'pending' });
244
+ await verne.gate.lifecycle.deleteRule('delete_unverified');
147
245
  ```
148
246
 
149
- Validate a token:
247
+ Only `delete_unverified` is in the Free plan; switching on another rule is `403 plan_required`.
248
+
249
+ ### Settings
150
250
 
151
251
  ```ts
152
- const info = await verne.gate.tokens.introspect(token.access_token);
252
+ const security = await verne.gate.settings.getSecurity();
253
+ // Both fields are required — the update is a full replacement, not a merge.
254
+ await verne.gate.settings.updateSecurity({ passwordless_enabled: true, mfa_enabled: false });
153
255
 
154
- if (!info.active) {
155
- // token is expired or invalid
156
- }
256
+ // Social login: where your backend receives the user (matched exactly)
257
+ await verne.gate.settings.updateReturnUrls(['https://app.example.com/auth/callback']);
258
+ const flow = await verne.gate.createLoginFlow({ state: csrfBoundValue }); // then, at your callback:
259
+ const { session_token } = await verne.gate.exchangeOidcCode(code);
157
260
  ```
158
261
 
159
- ### Authorization
262
+ ### Access tokens
160
263
 
161
- Check whether a subject is allowed to perform an action:
264
+ Short-lived opaque tokens for a user or a service account:
162
265
 
163
266
  ```ts
164
- const decision = await verne.gate.authorize({
267
+ const token = await verne.gate.tokens.create({
165
268
  subject: 'usr_123',
166
- action: 'relay.messages.read',
167
- resource: 'tenant:ten_001',
269
+ scopes: ['gate.tokens.read'], // optional
270
+ ttl_seconds: 3600, // optional, default 3600, max 86400
168
271
  });
169
272
 
170
- if (!decision.allowed) {
171
- throw new Error('Forbidden');
273
+ const info = await verne.gate.tokens.introspect(token.access_token);
274
+ if (!info.active) {
275
+ // expired, unknown, or its user is deactivated or deleted — `info` is just { active: false }
276
+ }
277
+ // For an end user, info.orgs lists their organizations, read now.
278
+ ```
279
+
280
+ ## Passepartout — Telegram sign-in
281
+
282
+ ```ts
283
+ const { nonce, deep_link } = await verne.passepartout.loginStart();
284
+ // send the user to deep_link, then poll:
285
+ const status = await verne.passepartout.loginStatus(nonce);
286
+ if (status.status === 'completed') {
287
+ const info = await verne.passepartout.introspect(status.access_token!);
288
+ info.orgs; // the user's organizations in your project
172
289
  }
173
290
  ```
174
291
 
175
292
  ## Clockwork — Cron-as-a-Service
176
293
 
177
- Schedule recurring and one-shot HTTP jobs. The `tenant_id` is automatically scoped to your API key.
294
+ Schedule recurring and one-shot HTTP jobs.
178
295
 
179
296
  ### Cron Jobs
180
297
 
@@ -195,7 +312,7 @@ const jobs = await verne.clockwork.jobs.list();
195
312
  // Update a job (partial — only the fields you pass are changed)
196
313
  await verne.clockwork.jobs.update(job.id, { schedule: '0 3 * * *', is_active: false });
197
314
 
198
- // Inspect the execution history (returns Execution[])
315
+ // Inspect the execution history (returns Execution[], one row per attempt)
199
316
  const runs = await verne.clockwork.jobs.executions(job.id);
200
317
 
201
318
  // Delete a job
@@ -207,52 +324,69 @@ await verne.clockwork.jobs.delete(job.id);
207
324
  One-shot jobs that fire once at a specific time:
208
325
 
209
326
  ```ts
210
- // Schedule a delayed job
211
327
  const delayed = await verne.clockwork.delayed.create({
212
328
  name: 'send-reminder',
213
329
  run_at: '2026-01-01T09:00:00Z', // ISO 8601 timestamp
214
330
  url: 'https://example.com/hooks/reminder',
215
331
  });
216
332
 
217
- // List all delayed jobs (returns DelayedJob[])
218
333
  const pending = await verne.clockwork.delayed.list();
219
-
220
- // Inspect the execution history (returns Execution[])
221
334
  const runs = await verne.clockwork.delayed.executions(delayed.id);
222
-
223
- // Cancel a pending job
224
335
  await verne.clockwork.delayed.cancel(delayed.id);
225
336
  ```
226
337
 
338
+ ### Usage
339
+
340
+ ```ts
341
+ const usage = await verne.clockwork.usage();
342
+ // { plan: 'free', cron: { active: 3, limit: 10 }, delayed: { pending: 12, limit: 100 } }
343
+ ```
344
+
227
345
  ## Error Handling
228
346
 
229
- All API errors throw a `VerneAPIError` with structured fields:
347
+ API errors throw a `VerneAPIError`:
230
348
 
231
349
  ```ts
232
350
  import { VerneAPIError } from '@verne-software/sdk';
233
351
 
234
352
  try {
235
- await verne.relay.messages.send({ event_type: 'ping', payload: {} });
353
+ await verne.gate.organizations.members.add(orgId, { identity_id, role_id });
236
354
  } catch (err) {
237
355
  if (err instanceof VerneAPIError) {
238
- console.error(err.code); // e.g. 'invalid_payload', 'unauthorized'
239
- console.error(err.status); // HTTP status code
240
- console.error(err.requestId); // include in support requests
356
+ err.status; // HTTP status
357
+ err.code; // e.g. 'conflict' — branch on status + code
358
+ err.message; // a sentence to show a person
359
+ err.details; // extra members of the error, e.g. { plan, limit, current }
360
+ err.retryAfter; // seconds, when the response had Retry-After
361
+ err.requestId; // include in support requests
241
362
  }
242
363
  }
243
364
  ```
244
365
 
245
- Network failures and timeouts throw a base `VerneError`.
366
+ | Status | `code` | Meaning |
367
+ |:--|:--|:--|
368
+ | 400 | `invalid_payload` | Something in the request is malformed; `message` says what. |
369
+ | 401 | `unauthorized` | No key, or a revoked or invalid one; for a session, a dead session. |
370
+ | 402 | `ACTIVE_USER_LIMIT_REACHED`, `UPGRADE_REQUIRED`, `JOB_LIMIT_REACHED`, `BILLING_HARD_BLOCKED`, `SUBSCRIPTION_PAST_DUE` | A plan or billing limit. Does not lift by waiting — the SDK never retries it. |
371
+ | 403 | `forbidden` | The key lacks the scope for this API (`message` names it), or the resource is not yours. |
372
+ | 403 | `plan_required`, `mfa_required`, `account_disabled` | A rule not in your plan; a second factor owed; a deactivated Telegram user. |
373
+ | 404 | `not_found` | No such thing in your project. |
374
+ | 409 | `conflict`, `limit_reached` | It already exists **in your account** (an end user with that address, a role name, a membership), a role still held, or a cap reached. |
375
+ | 429 | `rate_limited`, `RELAY_MESSAGE_LIMIT_REACHED` | Too fast, or Relay's monthly limit. |
376
+ | 5xx | `upstream_unavailable`, `internal_error` | Retry; quote `requestId` to support. |
377
+
378
+ Codes are an open set; treat unknown ones generically. Every API route, Clockwork's included, answers in this envelope with a string `code` and a `requestId`. A body in another shape (one that did not come from the API — a proxy in between, an identity-service answer passed through on `/auth/*`) still becomes a `VerneAPIError`, with `code: 'unknown'` and whatever sentence it had in `message`.
379
+
380
+ **Retries.** A `POST` that gets a short `429` (a `Retry-After` of a minute or less) is retried once after waiting. Relay's monthly limit (`429 RELAY_MESSAGE_LIMIT_REACHED`, `Retry-After` until the 1st) and every `402` are thrown at once.
381
+
382
+ Network failures and timeouts throw a base `VerneError`; a refused signed token throws `VerneTokenError`.
246
383
 
247
384
  ## Configuration
248
385
 
249
- Both `Verne` and the per-service clients accept an optional `timeoutMs` (default `30000`):
386
+ `Verne` and the per-service clients accept `baseUrl` and `timeoutMs` (default `30000`):
250
387
 
251
388
  ```ts
252
- const verne = new Verne({
253
- relay: process.env.VERNE_RELAY_KEY,
254
- timeoutMs: 10_000,
255
- });
389
+ const verne = new Verne({ apiKey: process.env.VERNE_API_KEY, timeoutMs: 10_000 });
256
390
  ```
257
391
 
258
392
  Individual requests can be cancelled with an `AbortSignal`:
@@ -266,6 +400,35 @@ await verne.relay.messages.send(
266
400
  );
267
401
  ```
268
402
 
403
+ ## Development
404
+
405
+ ```bash
406
+ npm test # unit tests (no network)
407
+ npm run lint
408
+ npm run typecheck
409
+ npm run build
410
+ ```
411
+
412
+ ### Integration tests
413
+
414
+ `npm run test:integration` runs every resource against a real Nautilus stack. It is opt-in and skipped without a key; it creates end users, an organization and settings changes, and removes them again.
415
+
416
+ | Variable | |
417
+ |:--|:--|
418
+ | `VERNE_API_KEY` | A key with the scopes `gate`, `clockwork`, `passepartout`. Required. |
419
+ | `VERNE_BASE_URL` | Default `http://api.soft.com` (the local stack). Any other host needs `VERNE_ALLOW_REMOTE=1`. |
420
+ | `VERNE_EMAIL_PREFIX` | Every end user created is `<prefix>-<name>@e2e.soft.com`. |
421
+ | `VERNE_NARROW_KEY` | Optional: a key **without** the `gate` scope, to check the `403`. |
422
+
423
+ Against a local stack, `scripts/local-stack-key.mjs` registers a throwaway `e2e-` tenant and mints the keys:
424
+
425
+ ```bash
426
+ node scripts/local-stack-key.mjs create /tmp/sdk-it.json
427
+ eval "$(node scripts/local-stack-key.mjs env /tmp/sdk-it.json)"
428
+ npm run test:integration
429
+ node scripts/local-stack-key.mjs revoke /tmp/sdk-it.json # then purge the tenant it names
430
+ ```
431
+
269
432
  ## License
270
433
 
271
434
  [MIT](LICENSE)