@spheredata/sdk 0.0.1
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 +21 -0
- package/README.md +71 -0
- package/package.json +19 -0
- package/sphere-client.js +332 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Josh Zhang, Zihuai He
|
|
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
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# @spheredata/sdk
|
|
2
|
+
|
|
3
|
+
The SPHERE client SDK — the single integration surface for the SPHERE metered AI
|
|
4
|
+
backend. One client for the web portal, the Electron desktop app, and the CLI;
|
|
5
|
+
surfaces differ only in *where the token is stored* and *which tools run locally*.
|
|
6
|
+
|
|
7
|
+
The product/brand is **SPHERE** — this package is published under the
|
|
8
|
+
`@spheredata` npm scope.
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm i @spheredata/sdk
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quick start
|
|
17
|
+
|
|
18
|
+
```js
|
|
19
|
+
import { createSphere } from "@spheredata/sdk";
|
|
20
|
+
|
|
21
|
+
const sphere = createSphere({ portal: "adrc" }); // web: sessionStorage by default
|
|
22
|
+
|
|
23
|
+
await sphere.signIn({ email, password });
|
|
24
|
+
const res = await sphere.streamAgent({ messages, tools }); // SSE Response, one turn/call
|
|
25
|
+
const balance = await sphere.getBalance();
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The `portal` slug is sent as `?portal=<slug>` on every metered request so the
|
|
29
|
+
backend applies that client's config (model, margin) and tags usage.
|
|
30
|
+
|
|
31
|
+
## `createSphere(options)`
|
|
32
|
+
|
|
33
|
+
| Option | Default | Purpose |
|
|
34
|
+
| --- | --- | --- |
|
|
35
|
+
| `portal` | `"adrc"` | Attribution slug; sets server-side config + usage tag. |
|
|
36
|
+
| `tokenStore` | `sessionStorage` (else in-memory) | Sync string KV `{ get, set, clear }`. Inject `safeStorage` (desktop) or a file store (CLI). |
|
|
37
|
+
| `onAuthInvalid` | no-op | Called on any hard auth failure (store is cleared) — drop to a login screen. |
|
|
38
|
+
| `fetch` | `globalThis.fetch` | Override the fetch implementation. |
|
|
39
|
+
| `apiBase`, `appId` | SPHERE defaults | Point at a different backend / app. |
|
|
40
|
+
|
|
41
|
+
> **Token store contract:** operations must be **synchronous and atomic** —
|
|
42
|
+
> single-flight refresh relies on the rotated bundle being persisted before the
|
|
43
|
+
> next read. The SDK refreshes transparently (proactive near-expiry,
|
|
44
|
+
> retry-once-on-401).
|
|
45
|
+
|
|
46
|
+
## Client methods
|
|
47
|
+
|
|
48
|
+
`createSphere()` returns:
|
|
49
|
+
|
|
50
|
+
- **Auth:** `signUp`, `signIn`, `signOut`, `oauthUrl`, `isSignedIn()`
|
|
51
|
+
- **Agent:** `ask`, `streamAgent(payload, signal) → Response` (SSE, one model turn per call)
|
|
52
|
+
- **Account / wallet:** `getAccount`, `getBalance`, `listCredits`, `buyCredits`, `redeem`
|
|
53
|
+
- **Metering:** `charge({ bytes, op, tag })` — **pre-charge** before a data op; returns
|
|
54
|
+
`{ ok:false, insufficient:true, ... }` on a 402 (an expected business outcome, not thrown).
|
|
55
|
+
When charging is gated off server-side, returns `{ ok:true, charged:false, cost_usd:0 }`.
|
|
56
|
+
- **Identity:** `portal`, `appId`, `apiBase`
|
|
57
|
+
|
|
58
|
+
A ready-made default instance for the ADRC portal is also exported:
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
import { sphere } from "@spheredata/sdk";
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Pairs with
|
|
65
|
+
|
|
66
|
+
[`@spheredata/embed`](https://www.npmjs.com/package/@spheredata/embed) — the
|
|
67
|
+
`<sphere-agent>` web component. Pass the client in via `el.sphere = sphere`.
|
|
68
|
+
|
|
69
|
+
## License
|
|
70
|
+
|
|
71
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@spheredata/sdk",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"publishConfig": { "access": "public" },
|
|
5
|
+
"description": "SPHERE client SDK — talks to the SPHERE backend (auth, metered agent, account).",
|
|
6
|
+
"keywords": ["sphere", "spheredata", "sdk", "ai", "agent", "metering", "wallet", "synthetic-data"],
|
|
7
|
+
"homepage": "https://github.com/statzihuai/sphere-app-dev/tree/main/packages/sdk#readme",
|
|
8
|
+
"repository": { "type": "git", "url": "git+https://github.com/statzihuai/sphere-app-dev.git", "directory": "packages/sdk" },
|
|
9
|
+
"contributors": ["Josh Zhang", "Zihuai He"],
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "sphere-client.js",
|
|
12
|
+
"module": "sphere-client.js",
|
|
13
|
+
"exports": { ".": "./sphere-client.js" },
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "node --test"
|
|
16
|
+
},
|
|
17
|
+
"files": ["sphere-client.js"],
|
|
18
|
+
"license": "MIT"
|
|
19
|
+
}
|
package/sphere-client.js
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// SPHERE client SDK — the single integration surface every surface drops in.
|
|
2
|
+
//
|
|
3
|
+
// One client for the web portal, the Electron desktop app, and the CLI. Each
|
|
4
|
+
// integrates identically; surfaces differ only in *where the token is stored*
|
|
5
|
+
// (inject `tokenStore`) and *which tools run locally*:
|
|
6
|
+
//
|
|
7
|
+
// import { createSphere } from "@spheredata/sdk";
|
|
8
|
+
// const sphere = createSphere({ portal: "adrc" }); // web (sessionStorage)
|
|
9
|
+
// createSphere({ portal: "desktop", tokenStore: safeStorageAdapter });
|
|
10
|
+
// createSphere({ portal: "cli", tokenStore: fileAdapter });
|
|
11
|
+
//
|
|
12
|
+
// The portal slug is sent as ?portal=<slug> on every metered request so the
|
|
13
|
+
// backend applies that client's config (model, margin) and tags usage.
|
|
14
|
+
//
|
|
15
|
+
// Auth model (Butterbase native): login/refresh return
|
|
16
|
+
// { access_token, refresh_token, expires_in, user }
|
|
17
|
+
// access_token ~1h, refresh_token rotating. The SDK persists the full bundle via
|
|
18
|
+
// the injected `tokenStore` and refreshes transparently (single-flight, proactive
|
|
19
|
+
// near-expiry, retry-once on 401). On ANY hard auth failure it clears the store
|
|
20
|
+
// and calls `onAuthInvalid` so the surface can drop to a login screen.
|
|
21
|
+
|
|
22
|
+
const DEFAULTS = {
|
|
23
|
+
apiBase: "https://api.butterbase.ai",
|
|
24
|
+
appId: "app_wvujnt4zpp55",
|
|
25
|
+
portal: "adrc",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Refresh this many ms BEFORE the access token's expiry, to avoid a guaranteed
|
|
29
|
+
// 401 mid-call (and, for streams, mid-body where we cannot retry).
|
|
30
|
+
const REFRESH_SKEW_MS = 60_000;
|
|
31
|
+
|
|
32
|
+
// A token store is a tiny string KV: { get(): string|null, set(v: string), clear() }.
|
|
33
|
+
// CONTRACT: its operations MUST be synchronous and atomic — single-flight refresh
|
|
34
|
+
// relies on a rotated bundle being persisted before the next read. Sync adapters
|
|
35
|
+
// are always feasible: Electron `safeStorage` is synchronous, and a CLI file store
|
|
36
|
+
// can use `readFileSync`/`writeFileSync`. The SDK serializes the full bundle to/from
|
|
37
|
+
// JSON, so stores stay dumb. Default: sessionStorage when present, else in-memory —
|
|
38
|
+
// so importing the SDK in Node never throws.
|
|
39
|
+
function defaultTokenStore(key) {
|
|
40
|
+
const ss = typeof globalThis !== "undefined" ? globalThis.sessionStorage : undefined;
|
|
41
|
+
if (ss && typeof ss.getItem === "function") {
|
|
42
|
+
return {
|
|
43
|
+
get: () => ss.getItem(key),
|
|
44
|
+
set: (v) => ss.setItem(key, v),
|
|
45
|
+
clear: () => ss.removeItem(key),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
let mem = null;
|
|
49
|
+
return {
|
|
50
|
+
get: () => mem,
|
|
51
|
+
set: (v) => { mem = v; },
|
|
52
|
+
clear: () => { mem = null; },
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function createSphere(opts = {}) {
|
|
57
|
+
const { apiBase, appId, portal } = { ...DEFAULTS, ...opts };
|
|
58
|
+
const doFetch = opts.fetch || globalThis.fetch;
|
|
59
|
+
const onAuthInvalid = typeof opts.onAuthInvalid === "function" ? opts.onAuthInvalid : () => {};
|
|
60
|
+
|
|
61
|
+
const authBase = `${apiBase}/auth/${appId}`;
|
|
62
|
+
const fnBase = `${apiBase}/v1/${appId}/fn`;
|
|
63
|
+
const billingBase = `${apiBase}/v1/${appId}/billing`;
|
|
64
|
+
const q = `?portal=${encodeURIComponent(portal)}`;
|
|
65
|
+
const TOKEN_KEY = `sphere_token:${portal}`;
|
|
66
|
+
|
|
67
|
+
const tokenStore = opts.tokenStore || defaultTokenStore(TOKEN_KEY);
|
|
68
|
+
|
|
69
|
+
// --- bundle persistence ---------------------------------------------------
|
|
70
|
+
// Stored shape: { access_token, refresh_token, expires_at, user }.
|
|
71
|
+
function loadBundle() {
|
|
72
|
+
const raw = tokenStore.get();
|
|
73
|
+
if (!raw) return null;
|
|
74
|
+
if (typeof raw === "object") return raw; // tolerate a bundle-aware store (null excluded above)
|
|
75
|
+
try { return JSON.parse(raw); } catch { return null; }
|
|
76
|
+
}
|
|
77
|
+
function saveBundle(b) {
|
|
78
|
+
tokenStore.set(JSON.stringify(b)); // may throw (e.g. locked keychain) — callers route to hardFail
|
|
79
|
+
}
|
|
80
|
+
function bundleFromAuthResponse(data) {
|
|
81
|
+
const ttlSec = Number(data.expires_in);
|
|
82
|
+
const ttl = Number.isFinite(ttlSec) && ttlSec > 0 ? ttlSec : 3600; // seconds; ~1h fallback
|
|
83
|
+
return {
|
|
84
|
+
access_token: data.access_token,
|
|
85
|
+
refresh_token: data.refresh_token ?? null,
|
|
86
|
+
expires_at: Date.now() + ttl * 1000,
|
|
87
|
+
user: data.user ?? null,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Any unrecoverable auth failure converges here: clear the store and signal the
|
|
92
|
+
// surface, then throw. clear() and onAuthInvalid() are each guarded so neither
|
|
93
|
+
// can suppress the other or the throw.
|
|
94
|
+
function hardFail(message) {
|
|
95
|
+
try { tokenStore.clear(); } catch { /* store may be unavailable; still signal */ }
|
|
96
|
+
try { onAuthInvalid(); } catch { /* surface callback must not mask the failure */ }
|
|
97
|
+
throw new Error(message);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- auth -----------------------------------------------------------------
|
|
101
|
+
async function signUp(email, password, displayName) {
|
|
102
|
+
const r = await doFetch(`${authBase}/signup`, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { "Content-Type": "application/json" },
|
|
105
|
+
body: JSON.stringify({ email, password, display_name: displayName }),
|
|
106
|
+
});
|
|
107
|
+
if (!r.ok) throw new Error(`signup failed: ${r.status}`); // don't echo the body (may reflect input)
|
|
108
|
+
return r.json();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function signIn(email, password) {
|
|
112
|
+
const r = await doFetch(`${authBase}/login`, {
|
|
113
|
+
method: "POST",
|
|
114
|
+
headers: { "Content-Type": "application/json" },
|
|
115
|
+
body: JSON.stringify({ email, password }),
|
|
116
|
+
});
|
|
117
|
+
if (!r.ok) throw new Error(`login failed: ${r.status}`);
|
|
118
|
+
const data = await r.json();
|
|
119
|
+
saveBundle(bundleFromAuthResponse(data));
|
|
120
|
+
return data.user;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Single-flight refresh: concurrent callers share one in-flight promise so two
|
|
124
|
+
// 401s don't both rotate the refresh token and invalidate each other.
|
|
125
|
+
let refreshing = null;
|
|
126
|
+
async function refresh() {
|
|
127
|
+
if (refreshing) return refreshing;
|
|
128
|
+
refreshing = (async () => {
|
|
129
|
+
const b = loadBundle();
|
|
130
|
+
if (!b?.refresh_token) hardFail("session expired");
|
|
131
|
+
const r = await doFetch(`${authBase}/refresh`, {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers: { "Content-Type": "application/json" },
|
|
134
|
+
body: JSON.stringify({ refresh_token: b.refresh_token }),
|
|
135
|
+
});
|
|
136
|
+
if (!r.ok) hardFail(`refresh failed: ${r.status}`);
|
|
137
|
+
const data = await r.json();
|
|
138
|
+
try {
|
|
139
|
+
saveBundle(bundleFromAuthResponse(data)); // persist the ROTATED pair
|
|
140
|
+
} catch {
|
|
141
|
+
// Rotation succeeded server-side but we can't persist it — the old refresh
|
|
142
|
+
// token is now consumed. Fail hard so the surface re-auths instead of
|
|
143
|
+
// silently replaying a dead token.
|
|
144
|
+
hardFail("token persist failed");
|
|
145
|
+
}
|
|
146
|
+
return data.access_token;
|
|
147
|
+
})();
|
|
148
|
+
try {
|
|
149
|
+
return await refreshing;
|
|
150
|
+
} finally {
|
|
151
|
+
refreshing = null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Return a valid access token, proactively refreshing within the skew window.
|
|
156
|
+
// - no session at all -> throw "not signed in"
|
|
157
|
+
// - expired/near-expiry, can refresh -> refresh()
|
|
158
|
+
// - expired/near-expiry, cannot -> hardFail (clear + signal)
|
|
159
|
+
// A missing expires_at is treated as expired (never "immortal").
|
|
160
|
+
async function validAccessToken() {
|
|
161
|
+
const b = loadBundle();
|
|
162
|
+
if (!b?.access_token) throw new Error("not signed in");
|
|
163
|
+
const stale = !b.expires_at || Date.now() > b.expires_at - REFRESH_SKEW_MS;
|
|
164
|
+
if (stale) {
|
|
165
|
+
if (b.refresh_token) return refresh();
|
|
166
|
+
hardFail("session expired");
|
|
167
|
+
}
|
|
168
|
+
return b.access_token;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Authenticated fetch with retry-once-on-401. NOT for streams (see streamAgent).
|
|
172
|
+
async function authedFetch(url, init = {}) {
|
|
173
|
+
const token = await validAccessToken();
|
|
174
|
+
const withAuth = (t) => ({
|
|
175
|
+
...init,
|
|
176
|
+
headers: { ...(init.headers || {}), Authorization: `Bearer ${t}` },
|
|
177
|
+
});
|
|
178
|
+
let r = await doFetch(url, withAuth(token));
|
|
179
|
+
if (r.status === 401) {
|
|
180
|
+
if (!loadBundle()?.refresh_token) hardFail("session expired");
|
|
181
|
+
const fresh = await refresh(); // throws + signals on hard failure
|
|
182
|
+
r = await doFetch(url, withAuth(fresh));
|
|
183
|
+
}
|
|
184
|
+
return r;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function signOut() {
|
|
188
|
+
const b = loadBundle();
|
|
189
|
+
// Best-effort server-side revoke of the refresh token (network only in the try).
|
|
190
|
+
if (b?.access_token) {
|
|
191
|
+
try {
|
|
192
|
+
await doFetch(`${authBase}/logout`, {
|
|
193
|
+
method: "POST",
|
|
194
|
+
headers: { Authorization: `Bearer ${b.access_token}` },
|
|
195
|
+
});
|
|
196
|
+
} catch { /* ignore network/HTTP errors on logout */ }
|
|
197
|
+
}
|
|
198
|
+
tokenStore.clear();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Build the hosted OAuth start URL; the surface opens it (web redirect or
|
|
202
|
+
// desktop/CLI loopback) and captures the returned tokens. `redirectTo` must be
|
|
203
|
+
// an absolute http(s) URL — reject other schemes (javascript:, data:, relative)
|
|
204
|
+
// to avoid handing the auth flow somewhere unintended. Host allow-listing is the
|
|
205
|
+
// backend's responsibility.
|
|
206
|
+
function oauthUrl(provider, redirectTo) {
|
|
207
|
+
const base = `${authBase}/oauth/${encodeURIComponent(provider)}`;
|
|
208
|
+
if (!redirectTo) return base;
|
|
209
|
+
let u;
|
|
210
|
+
try { u = new URL(redirectTo); } catch { throw new Error("oauthUrl: redirectTo must be an absolute URL"); }
|
|
211
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") throw new Error("oauthUrl: redirectTo must be http(s)");
|
|
212
|
+
return `${base}?redirect_to=${encodeURIComponent(redirectTo)}`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// --- AI -------------------------------------------------------------------
|
|
216
|
+
// Ask the model through SPHERE. Metered to the user's wallet under this portal.
|
|
217
|
+
async function ask(messages, { model, max_tokens } = {}) {
|
|
218
|
+
const r = await authedFetch(`${fnBase}/messages${q}`, {
|
|
219
|
+
method: "POST",
|
|
220
|
+
headers: { "Content-Type": "application/json" },
|
|
221
|
+
body: JSON.stringify({
|
|
222
|
+
messages,
|
|
223
|
+
...(model ? { model } : {}),
|
|
224
|
+
...(max_tokens != null ? { max_tokens } : {}),
|
|
225
|
+
}),
|
|
226
|
+
});
|
|
227
|
+
const data = await r.json();
|
|
228
|
+
if (!r.ok) throw new Error(data?.error || `message failed: ${r.status}`);
|
|
229
|
+
return data; // { message, model, portal, usage, cost_usd, balance_usd }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Open a streaming agent turn. Returns the raw Response (text/event-stream):
|
|
233
|
+
// OpenAI chat.completion.chunk lines + a trailing
|
|
234
|
+
// `data: {"sphere":{balance_usd,cost_usd,usage,portal}}` event. One model turn
|
|
235
|
+
// per call; the caller runs the tool loop, feeding tool results back.
|
|
236
|
+
//
|
|
237
|
+
// Pre-flight refresh + retry-once ONLY on the initial response status (body not
|
|
238
|
+
// yet read — safe to re-open). We never retry once the body is being consumed.
|
|
239
|
+
async function streamAgent(payload, signal) {
|
|
240
|
+
const open = (t) => doFetch(`${fnBase}/agent${q}`, {
|
|
241
|
+
method: "POST",
|
|
242
|
+
signal,
|
|
243
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${t}` },
|
|
244
|
+
body: JSON.stringify(payload),
|
|
245
|
+
});
|
|
246
|
+
let r = await open(await validAccessToken());
|
|
247
|
+
if (r.status === 401) {
|
|
248
|
+
if (!loadBundle()?.refresh_token) hardFail("session expired");
|
|
249
|
+
r = await open(await refresh());
|
|
250
|
+
}
|
|
251
|
+
return r;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// --- account / wallet -----------------------------------------------------
|
|
255
|
+
async function getAccount() {
|
|
256
|
+
const r = await authedFetch(`${fnBase}/account${q}`, {});
|
|
257
|
+
if (!r.ok) throw new Error(`account failed: ${r.status}`);
|
|
258
|
+
return r.json(); // { user, portal, wallet, recent_usage }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function getBalance() {
|
|
262
|
+
const r = await authedFetch(`${fnBase}/balance`, {});
|
|
263
|
+
if (!r.ok) throw new Error(`balance failed: ${r.status}`);
|
|
264
|
+
return r.json();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// --- billing (credit purchase) --------------------------------------------
|
|
268
|
+
// NOTE: backend shapes are pending the Slice-1 billing spike; these wrappers
|
|
269
|
+
// follow the documented Butterbase Stripe Connect / SPHERE redeem contract.
|
|
270
|
+
async function listCredits() {
|
|
271
|
+
const r = await authedFetch(`${billingBase}/products`, {});
|
|
272
|
+
if (!r.ok) throw new Error(`listCredits failed: ${r.status}`);
|
|
273
|
+
return r.json(); // [{ productId, name, credit_usd, priceCents }]
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function buyCredits(productId, { successUrl, cancelUrl } = {}) {
|
|
277
|
+
const r = await authedFetch(`${billingBase}/purchase`, {
|
|
278
|
+
method: "POST",
|
|
279
|
+
headers: { "Content-Type": "application/json" },
|
|
280
|
+
body: JSON.stringify({ productId, ...(successUrl ? { successUrl } : {}), ...(cancelUrl ? { cancelUrl } : {}) }),
|
|
281
|
+
});
|
|
282
|
+
const data = await r.json();
|
|
283
|
+
if (!r.ok) throw new Error(data?.error || `buyCredits failed: ${r.status}`);
|
|
284
|
+
return data; // { url, orderId }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function redeem(orderId) {
|
|
288
|
+
const r = await authedFetch(`${fnBase}/redeem`, {
|
|
289
|
+
method: "POST",
|
|
290
|
+
headers: { "Content-Type": "application/json" },
|
|
291
|
+
body: JSON.stringify({ orderId }),
|
|
292
|
+
});
|
|
293
|
+
const data = await r.json();
|
|
294
|
+
if (!r.ok) throw new Error(data?.error || `redeem failed: ${r.status}`);
|
|
295
|
+
return data; // { balance_usd }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// --- data-op metering -----------------------------------------------------
|
|
299
|
+
// Charge a local data operation (generate/evaluate/certify) by input dataset
|
|
300
|
+
// bytes, debited from the wallet under this portal/tag. Pricing is server-side.
|
|
301
|
+
// PRE-CHARGE: call this BEFORE running the op; if `ok` is false (insufficient
|
|
302
|
+
// credits) do NOT run it. A 402 is an expected business outcome (not thrown);
|
|
303
|
+
// other failures throw. When charging is gated off server-side, returns
|
|
304
|
+
// { ok:true, charged:false, cost_usd:0 } and never blocks.
|
|
305
|
+
async function charge({ bytes, op, tag } = {}) {
|
|
306
|
+
const r = await authedFetch(`${fnBase}/charge${q}`, {
|
|
307
|
+
method: "POST",
|
|
308
|
+
headers: { "Content-Type": "application/json" },
|
|
309
|
+
body: JSON.stringify({ bytes, op, ...(tag ? { tag } : {}) }),
|
|
310
|
+
});
|
|
311
|
+
const data = await r.json().catch(() => ({}));
|
|
312
|
+
if (r.status === 402) {
|
|
313
|
+
return { ok: false, insufficient: true, ...data }; // { cost_usd, balance_usd, ... }
|
|
314
|
+
}
|
|
315
|
+
if (!r.ok) throw new Error(data?.error || `charge failed: ${r.status}`);
|
|
316
|
+
return data; // { ok:true, charged, op, bytes, cost_usd, balance_usd }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
portal, appId, apiBase,
|
|
321
|
+
signUp, signIn, signOut, oauthUrl,
|
|
322
|
+
ask, streamAgent,
|
|
323
|
+
getAccount, getBalance,
|
|
324
|
+
listCredits, buyCredits, redeem, charge,
|
|
325
|
+
// NOTE: reflects token PRESENCE, not validity — a present-but-expired token
|
|
326
|
+
// returns true here and is refreshed lazily on the next call.
|
|
327
|
+
isSignedIn: () => !!loadBundle()?.access_token,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Default instance for the ADRC portal (safe to construct in any environment).
|
|
332
|
+
export const sphere = createSphere();
|