@onvoi/app-sdk 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Onvoi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,36 @@
1
- # Temporary Holding Version
1
+ # @onvoi/app-sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Optional helpers for apps hosted on Onvoi. Nothing here is required — every
4
+ call wraps a documented HTTP contract, so any JWT library and `fetch` work too.
5
+ Zero dependencies; Node 18+.
6
+
7
+ ```js
8
+ const { onvoi } = require('@onvoi/app-sdk');
9
+
10
+ // Who is signed in (verifies X-Onvoi-Identity against Onvoi's published keys)
11
+ const me = await onvoi.identity.verify(req); // Express req, Lambda event, or the raw header
12
+
13
+ // Your app's database, with that person's permissions
14
+ const items = onvoi.data.collection('items');
15
+ await items.find({ sku: 'A1' }, { as: me });
16
+ await items.create({ sku: 'B2', qty: 3 }, { as: me });
17
+
18
+ // Files (needs services.objectStorage.provision: true in onvoi.yaml)
19
+ const file = await onvoi.files.upload('receipt.pdf', pdfBuffer, { contentType: 'application/pdf', as: me });
20
+ const { url } = await onvoi.files.get(file.id, { as: me }); // short-lived download link
21
+
22
+ // Events from your app's catalog, and approved actions of other apps
23
+ await onvoi.events.publish('inventory.stock.low', { sku: 'A1' }, { idempotencyKey: 'A1-2026-10-24' });
24
+ await onvoi.apps.call('crm', 'crm.contacts.lookup', { email: 'a@b.co' }, { as: me });
25
+ // Retrying a write? Reuse the same idempotencyKey and it runs only once:
26
+ await onvoi.apps.call('crm', 'crm.contacts.create', { email: 'a@b.co' }, { as: me, idempotencyKey: 'signup-123' });
27
+ // Containers: keep the platform token fresh, and answer health checks
28
+ app.use(onvoi.middleware());
29
+ onvoi.health.express(app); // GET /healthz → 200 (set runtime.health.path)
30
+ ```
31
+
32
+ The platform sets everything it needs in the environment: `ONVOI_ENV_ID`,
33
+ `ONVOI_OIDC_ISSUER`, `ONVOI_JWKS_URL`, `ONVOI_DATA_API_URL`,
34
+ `ONVOI_PLATFORM_URL` and `ONVOI_WORKLOAD_TOKEN` (refreshed on every request).
35
+
36
+ MIT licensed.
package/package.json CHANGED
@@ -1,6 +1,11 @@
1
1
  {
2
2
  "name": "@onvoi/app-sdk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Optional helpers for apps hosted on Onvoi: verify the signed-in person, use the app's database, publish events, call other apps.",
5
+ "license": "MIT",
6
+ "main": "src/index.js",
7
+ "files": ["src", "README.md", "LICENSE"],
8
+ "engines": { "node": ">=18" },
9
+ "scripts": { "test": "node --test test/*.test.js" },
10
+ "keywords": ["onvoi", "sdk"]
11
+ }
package/src/index.js ADDED
@@ -0,0 +1,210 @@
1
+ /**
2
+ * @onvoi/app-sdk — optional helpers for apps hosted on Onvoi (spec-03 §4).
3
+ *
4
+ * Nothing here is required: every call is a thin wrapper over the documented
5
+ * HTTP contracts, and an app can use any JWT library and plain fetch instead.
6
+ * Zero dependencies (Node ≥ 18: global fetch + node:crypto).
7
+ *
8
+ * const { onvoi } = require('@onvoi/app-sdk');
9
+ * const me = await onvoi.identity.verify(req); // the signed-in person
10
+ * const items = onvoi.data.collection('items');
11
+ * await items.find({ sku: 'A1' }, { as: me }); // access rules evaluated as them
12
+ * await onvoi.events.publish('inventory.stock.low', { sku: 'A1' });
13
+ * await onvoi.apps.call('crm', 'crm.contacts.lookup', { email }, { as: me });
14
+ * onvoi.health.express(app); // GET /healthz → 200
15
+ *
16
+ * Environment (set by the platform): ONVOI_ENV_ID, ONVOI_OIDC_ISSUER,
17
+ * ONVOI_JWKS_URL, ONVOI_DATA_API_URL (or ONVOI_PUBLIC_ORIGIN),
18
+ * ONVOI_PLATFORM_URL, ONVOI_WORKLOAD_TOKEN (refreshed on every request).
19
+ */
20
+ const crypto = require('crypto');
21
+
22
+ class OnvoiError extends Error {
23
+ constructor(status, code, message, details) {
24
+ super(message || code || `HTTP ${status}`);
25
+ this.name = 'OnvoiError';
26
+ this.status = status;
27
+ this.code = code || 'error';
28
+ if (details) this.details = details;
29
+ }
30
+ }
31
+
32
+ function b64urlJson(part) {
33
+ return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
34
+ }
35
+
36
+ function headerOf(src, name) {
37
+ if (!src) return null;
38
+ if (typeof src === 'string') return src;
39
+ const h = src.headers || {};
40
+ if (typeof h.get === 'function') return h.get(name);
41
+ return h[name] || h[name.toLowerCase()] || null;
42
+ }
43
+
44
+ function createOnvoi(opts = {}) {
45
+ const env = opts.env || process.env;
46
+ const fetchImpl = opts.fetch || globalThis.fetch;
47
+ const now = opts.now || (() => Date.now());
48
+ let latestWorkload = null;
49
+ const jwks = { keys: null, at: 0 };
50
+
51
+ const cfg = () => ({
52
+ envId: env.ONVOI_ENV_ID,
53
+ issuer: env.ONVOI_OIDC_ISSUER,
54
+ jwksUrl: env.ONVOI_JWKS_URL || (env.ONVOI_OIDC_ISSUER ? `${env.ONVOI_OIDC_ISSUER}/jwks.json` : null),
55
+ dataUrl: env.ONVOI_DATA_API_URL || (env.ONVOI_PUBLIC_ORIGIN ? `${env.ONVOI_PUBLIC_ORIGIN}/api/data` : null),
56
+ platformUrl: env.ONVOI_PLATFORM_URL,
57
+ });
58
+
59
+ async function request(url, { method = 'GET', headers = {}, body } = {}) {
60
+ if (!fetchImpl) throw new OnvoiError(0, 'no_fetch', 'fetch is not available (Node 18+ required)');
61
+ const r = await fetchImpl(url, { method, headers: { accept: 'application/json', ...(body !== undefined ? { 'content-type': 'application/json' } : {}), ...headers }, body: body !== undefined ? JSON.stringify(body) : undefined });
62
+ const text = await r.text();
63
+ let data = null;
64
+ try { data = text ? JSON.parse(text) : null; } catch { data = text; }
65
+ if (!r.ok) {
66
+ const e = (data && data.error) || {};
67
+ throw new OnvoiError(r.status, e.code, e.message || `HTTP ${r.status}`, e.details);
68
+ }
69
+ return data;
70
+ }
71
+
72
+ /* ── identity ─────────────────────────────────────────────────────── */
73
+ async function loadKeys(force = false) {
74
+ if (!force && jwks.keys && now() - jwks.at < 10 * 60 * 1000) return jwks.keys;
75
+ const { jwksUrl } = cfg();
76
+ if (!jwksUrl) throw new OnvoiError(0, 'not_configured', 'ONVOI_JWKS_URL is not set');
77
+ const doc = await request(jwksUrl);
78
+ jwks.keys = (doc && doc.keys) || [];
79
+ jwks.at = now();
80
+ return jwks.keys;
81
+ }
82
+
83
+ /**
84
+ * Verify an X-Onvoi-Identity assertion (a string, a Node/Express request, a
85
+ * Lambda event, or a fetch Request). Resolves to its claims; `claims.token`
86
+ * (non-enumerable) is the raw assertion, used by `{ as }` options.
87
+ */
88
+ async function verify(src, { audience } = {}) {
89
+ const token = headerOf(src, 'x-onvoi-identity');
90
+ if (!token) throw new OnvoiError(401, 'no_identity', 'No X-Onvoi-Identity on this request');
91
+ const parts = String(token).split('.');
92
+ if (parts.length !== 3) throw new OnvoiError(401, 'identity_invalid', 'Malformed identity assertion');
93
+ const head = b64urlJson(parts[0]);
94
+ const claims = b64urlJson(parts[1]);
95
+ if (head.alg !== 'RS256') throw new OnvoiError(401, 'identity_invalid', 'Unexpected algorithm');
96
+ let keys = await loadKeys();
97
+ let jwk = keys.find((k) => k.kid === head.kid);
98
+ if (!jwk) { keys = await loadKeys(true); jwk = keys.find((k) => k.kid === head.kid); }
99
+ if (!jwk) throw new OnvoiError(401, 'identity_invalid', 'Unknown signing key');
100
+ const pub = crypto.createPublicKey({ key: { kty: jwk.kty, n: jwk.n, e: jwk.e }, format: 'jwk' });
101
+ const ok = crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), pub, Buffer.from(parts[2], 'base64url'));
102
+ if (!ok) throw new OnvoiError(401, 'identity_invalid', 'Bad signature');
103
+ const { issuer, envId } = cfg();
104
+ const aud = audience || envId;
105
+ const t = Math.floor(now() / 1000);
106
+ if (issuer && claims.iss !== issuer) throw new OnvoiError(401, 'identity_invalid', 'Wrong issuer');
107
+ if (aud && !(Array.isArray(claims.aud) ? claims.aud.includes(String(aud)) : claims.aud === String(aud))) throw new OnvoiError(401, 'identity_invalid', 'Assertion is for a different app environment');
108
+ if (typeof claims.exp === 'number' && claims.exp + 5 < t) throw new OnvoiError(401, 'identity_expired', 'Identity assertion expired');
109
+ if (typeof claims.nbf === 'number' && claims.nbf - 5 > t) throw new OnvoiError(401, 'identity_invalid', 'Identity assertion not yet valid');
110
+ Object.defineProperty(claims, 'token', { value: token, enumerable: false });
111
+ return claims;
112
+ }
113
+
114
+ const asHeader = (as) => {
115
+ const tok = !as ? null : typeof as === 'string' ? as : as.token || headerOf(as, 'x-onvoi-identity');
116
+ if (!tok) throw new OnvoiError(400, 'as_required', 'Pass { as: <verified identity> } so the call runs with that person\'s permissions');
117
+ return { 'x-onvoi-identity-forward': tok };
118
+ };
119
+
120
+ /* ── data ─────────────────────────────────────────────────────────── */
121
+ function collection(name) {
122
+ const base = () => {
123
+ const { dataUrl } = cfg();
124
+ if (!dataUrl) throw new OnvoiError(0, 'not_configured', 'ONVOI_DATA_API_URL is not set');
125
+ return `${dataUrl.replace(/\/+$/, '')}/${encodeURIComponent(name)}`;
126
+ };
127
+ return {
128
+ find: (filter = {}, { as, sort, limit, cursor } = {}) => request(`${base()}/query`, { method: 'POST', headers: asHeader(as), body: { filter, ...(sort ? { sort } : {}), ...(limit ? { limit } : {}), ...(cursor ? { cursor } : {}) } }),
129
+ get: (id, { as } = {}) => request(`${base()}/${encodeURIComponent(id)}`, { headers: asHeader(as) }),
130
+ create: (doc, { as } = {}) => request(base(), { method: 'POST', headers: asHeader(as), body: doc }),
131
+ update: (id, patch, { as, ifMatch } = {}) => request(`${base()}/${encodeURIComponent(id)}`, { method: 'PATCH', headers: { ...asHeader(as), ...(ifMatch ? { 'if-match': String(ifMatch) } : {}) }, body: patch }),
132
+ remove: (id, { as } = {}) => request(`${base()}/${encodeURIComponent(id)}`, { method: 'DELETE', headers: asHeader(as) }),
133
+ };
134
+ }
135
+
136
+ /* ── files (services.objectStorage) ───────────────────────────────── */
137
+ const filesBase = () => {
138
+ const { dataUrl } = cfg();
139
+ if (!dataUrl) throw new OnvoiError(0, 'not_configured', 'ONVOI_DATA_API_URL is not set');
140
+ return `${dataUrl.replace(/\/+$/, '')}/_files`;
141
+ };
142
+ const files = {
143
+ /** Upload bytes (Buffer/Uint8Array/string): reserve → PUT → confirm. */
144
+ async upload(name, bytes, { contentType = 'application/octet-stream', as } = {}) {
145
+ const body = typeof bytes === 'string' ? Buffer.from(bytes) : bytes;
146
+ const start = await request(filesBase(), { method: 'POST', headers: asHeader(as), body: { name, contentType, size: body.length } });
147
+ // Development stores bytes through the Data API itself; production
148
+ // hands out a presigned storage URL (no Onvoi headers there).
149
+ const viaDataApi = /\/_files\/[^/]+\/content$/.test(new URL(start.upload.url).pathname);
150
+ const r = await fetchImpl(start.upload.url, { method: 'PUT', headers: { ...start.upload.headers, ...(viaDataApi ? asHeader(as) : {}) }, body });
151
+ if (!r.ok) throw new OnvoiError(r.status, 'upload_failed', `Upload failed (HTTP ${r.status})`);
152
+ return viaDataApi ? r.json() : request(`${filesBase()}/${start.file.id}/complete`, { method: 'POST', headers: asHeader(as), body: {} });
153
+ },
154
+ list: ({ as, limit, cursor } = {}) => request(`${filesBase()}?${new URLSearchParams({ ...(limit ? { limit: String(limit) } : {}), ...(cursor ? { cursor } : {}) })}`, { headers: asHeader(as) }),
155
+ /** Details plus a short-lived download URL. */
156
+ get: (id, { as } = {}) => request(`${filesBase()}/${encodeURIComponent(id)}`, { headers: asHeader(as) }),
157
+ remove: (id, { as } = {}) => request(`${filesBase()}/${encodeURIComponent(id)}`, { method: 'DELETE', headers: asHeader(as) }),
158
+ };
159
+
160
+ /* ── platform (events, app-to-app) ────────────────────────────────── */
161
+ function workloadToken() {
162
+ const t = latestWorkload || env.ONVOI_WORKLOAD_TOKEN;
163
+ if (!t) throw new OnvoiError(0, 'not_configured', 'ONVOI_WORKLOAD_TOKEN is not set');
164
+ return t;
165
+ }
166
+ function platform(path, body, headers = {}) {
167
+ const { platformUrl } = cfg();
168
+ if (!platformUrl) throw new OnvoiError(0, 'not_configured', 'ONVOI_PLATFORM_URL is not set');
169
+ return request(`${platformUrl.replace(/\/+$/, '')}${path}`, { method: 'POST', headers: { authorization: `Bearer ${workloadToken()}`, ...headers }, body });
170
+ }
171
+
172
+ const events = {
173
+ /** Publish an event from this app's catalog. `id` defaults to idempotencyKey or a random id. */
174
+ publish: (type, data = {}, { id, idempotencyKey, correlationId, version } = {}) => platform('/events', {
175
+ id: id || idempotencyKey || crypto.randomUUID(), type, data, ...(version ? { version } : {}), ...(correlationId ? { correlationId } : {}),
176
+ }),
177
+ };
178
+
179
+ const apps = {
180
+ /** Call another app's approved action. `{ as }` acts for that person; without it, as this app. */
181
+ call: async (slug, capability, input = {}, { as, correlationId, idempotencyKey } = {}) => {
182
+ const headers = { ...(as ? asHeader(as) : {}), ...(correlationId ? { 'x-onvoi-correlation-id': correlationId } : {}), ...(idempotencyKey ? { 'idempotency-key': String(idempotencyKey) } : {}) };
183
+ const r = await platform(`/apps/${encodeURIComponent(slug)}/capabilities/${encodeURIComponent(capability)}`, input, headers);
184
+ return r && Object.prototype.hasOwnProperty.call(r, 'result') ? r.result : r;
185
+ },
186
+ };
187
+
188
+ /* ── server helpers ───────────────────────────────────────────────── */
189
+ /** Remember the fresh workload token Onvoi attaches to each request (containers). */
190
+ function capture(src) {
191
+ const t = headerOf(src, 'x-onvoi-workload-token');
192
+ if (t) latestWorkload = t;
193
+ return t;
194
+ }
195
+ /** Express/Connect middleware: keeps the workload token fresh. */
196
+ function middleware() {
197
+ return (req, res, next) => { capture(req); next(); };
198
+ }
199
+ const health = {
200
+ express: (app, { path = '/healthz' } = {}) => {
201
+ app.get(path, (req, res) => res.status(200).json({ ok: true }));
202
+ return app;
203
+ },
204
+ };
205
+
206
+ return { identity: { verify }, data: { collection }, files, events, apps, health, middleware, capture, OnvoiError, _loadKeys: loadKeys };
207
+ }
208
+
209
+ const onvoi = createOnvoi();
210
+ module.exports = { onvoi, createOnvoi, OnvoiError };