openzoo 0.48.89 → 0.48.94

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/lib/mcp.js CHANGED
@@ -8,6 +8,7 @@ import { tokenBalance } from './x402.js';
8
8
  import { askWithContext, contextCacheDisabled, BIND_MIN_CHARS } from './hrr.js';
9
9
  import { listContexts } from './contexts.js';
10
10
  import { withNamespace } from './namespace.js';
11
+ import { subscriptionPublicView } from './subscription.js';
11
12
 
12
13
  const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
13
14
  // The model zoo_ask uses when the caller does not name one. Opus 5 by default:
@@ -383,6 +384,7 @@ export function buildMcpServer() {
383
384
  fundHint: 'send a few cents of a listed asset to the address for that rail — Solana assets to solanaAddress, Base assets to evmAddress. The shim converts to whatever the 402 quotes, at payment time. Force a rail with OPENZOO_RAIL=solana|base|robinhood.',
384
385
  balances,
385
386
  receipts: client.receipts.map((r) => ({ at: r.at, line: r.line })),
387
+ subscription: subscriptionPublicView(),
386
388
  });
387
389
  });
388
390
 
package/lib/pay.js CHANGED
@@ -13,6 +13,7 @@ import { withNamespace } from './namespace.js';
13
13
  import {
14
14
  resolvePool, poolState, depositForShares, buildWrapInstructions, sendWrap,
15
15
  } from './wrap.js';
16
+ import { applySubscriptionHeaders, loadSubscription, stripAuthorization } from './subscription.js';
16
17
 
17
18
  export class QuoteTooHighError extends Error {
18
19
  constructor(billedUsd, quote) {
@@ -333,8 +334,15 @@ export class PayClient {
333
334
  // Contexts are tenanted by this namespace server-side — a request without
334
335
  // it cannot see corpora this wallet bound.
335
336
  init = { ...init, headers: withNamespace(init.headers || {}) };
337
+ // Subscription key · no x402. A stored Stripe key is a bearer on the zoo
338
+ // API (same as the public /billing/done snippet). Wallet/x402 stays if
339
+ // there is no key, or if the gateway still answers 402.
340
+ const sub = loadSubscription();
341
+ if (sub?.key) init = { ...init, headers: applySubscriptionHeaders(init.headers, sub) };
336
342
  const first = await fetch(url, init);
337
- if (first.status !== 402) return { response: first, paid: false };
343
+ if (first.status !== 402) {
344
+ return { response: first, paid: false, subscription: Boolean(sub?.key && first.ok) };
345
+ }
338
346
 
339
347
  const quote = parse402(await first.json());
340
348
  // config.rail (OPENZOO_RAIL) steers every front — proxy, demo, MCP — since
@@ -394,7 +402,7 @@ export class PayClient {
394
402
  onStage?.('paying');
395
403
  const response = await fetch(url, {
396
404
  ...init,
397
- headers: { ...(init.headers || {}), 'X-PAYMENT': payment.header },
405
+ headers: { ...stripAuthorization(init.headers || {}), 'X-PAYMENT': payment.header },
398
406
  });
399
407
  const settle = decodeSettleHeader(response.headers.get('x-payment-response'))
400
408
  || { signature: payment.ownerSignature };
package/lib/proxy.js CHANGED
@@ -24,6 +24,7 @@ import { anthropicToOpenAI, openAIToAnthropic, streamOpenAIToAnthropic, writeAnt
24
24
  import { responsesToChat, chatToResponses, writeResponsesSse } from './responses.js';
25
25
  import { loadSessionSpend, saveSessionSpend } from './session.js';
26
26
  import { creditBalance, quotedPrices } from './info.js';
27
+ import { subscriptionPublicView } from './subscription.js';
27
28
  import { priceHoldings } from './livestatus.js';
28
29
 
29
30
  const HOP_BY_HOP = new Set([
@@ -891,6 +892,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
891
892
  res.end(JSON.stringify({
892
893
  spentUsd: sessionSpent, cogsUsd: sessionCogs, directUsd: sessionDirect, paidCalls,
893
894
  creditUsd, chainUsd: money.chainUsd,
895
+ subscription: subscriptionPublicView(),
894
896
  }));
895
897
  return;
896
898
  }
@@ -914,6 +916,7 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
914
916
  creditUsd,
915
917
  chainUsd: money.chainUsd,
916
918
  holdings: money.holdings,
919
+ subscription: subscriptionPublicView(),
917
920
  }));
918
921
  return;
919
922
  }
@@ -1021,7 +1024,9 @@ export async function startProxy({ silent = false, requireToken = null, sessionM
1021
1024
  },
1022
1025
  mcp: `${self.replace(/\/v1$/, '')}/mcp`,
1023
1026
  upstream: config.apiBase,
1024
- payment: 'x402 per request from the operator\'s local burner wallet — no API key, no account',
1027
+ payment: subscriptionPublicView().active
1028
+ ? 'subscription key � no x402 � wallet/x402 remains the other method'
1029
+ : 'x402 per request from the operator\'s local burner wallet � no API key, no account',
1025
1030
  auth: viaTunnel
1026
1031
  ? 'this public URL requires the oz_… bearer for paid endpoints; /v1/models and /v1/hrr/bind are free'
1027
1032
  : 'localhost is keyless',
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Stripe subscription keys for the zoo API — the other pay lane next to
3
+ * wallet/x402. The live billing API is zoo.openzoo.fun; this file does not
4
+ * invent a second backend.
5
+ *
6
+ * After checkout the site lands on /billing/done?session=<cs_…> and polls
7
+ * GET /api/billing/key?session=… until Stripe confirms. That response is how
8
+ * a desktop client receives the key (no Stripe cookie on Electron). A user
9
+ * who already subscribed can paste the same key, or that success URL.
10
+ *
11
+ * Use: Authorization: Bearer <key> against x402-tokens.fly.dev — no 402
12
+ * signing. Wallet/x402 stays if no key is stored.
13
+ */
14
+ import fs from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+
18
+ export const BILLING_ORIGIN = 'https://zoo.openzoo.fun';
19
+ export const SUBSCRIPTIONS_PAGE = 'https://zoo.openzoo.fun/subscriptions';
20
+
21
+ export function subscriptionFile(home = os.homedir()) {
22
+ return process.env.OPENZOO_SUBSCRIPTION_PATH
23
+ || path.join(home, '.openzoo', 'subscription.json');
24
+ }
25
+
26
+ function titleCase(id) {
27
+ const s = String(id || '').trim();
28
+ if (!s) return '';
29
+ return s.charAt(0).toUpperCase() + s.slice(1);
30
+ }
31
+
32
+ function asKey(v) {
33
+ return String(v || '').trim();
34
+ }
35
+
36
+ /** Persist a subscription key (chmod 600). Never log the value. */
37
+ export function saveSubscription(rec, file = subscriptionFile()) {
38
+ const key = asKey(rec?.key);
39
+ if (!key) return null;
40
+ const payload = {
41
+ key,
42
+ tier: rec.tier ? String(rec.tier) : null,
43
+ tierName: rec.tierName ? String(rec.tierName) : (rec.tier ? titleCase(rec.tier) : null),
44
+ sessionId: rec.sessionId ? String(rec.sessionId) : null,
45
+ savedAt: Date.now(),
46
+ };
47
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
48
+ const tmp = `${file}.tmp`;
49
+ fs.writeFileSync(tmp, JSON.stringify(payload), { mode: 0o600 });
50
+ fs.renameSync(tmp, file);
51
+ cached = { file, mtime: fs.statSync(file).mtimeMs, data: payload };
52
+ return payload;
53
+ }
54
+
55
+ export function clearSubscription(file = subscriptionFile()) {
56
+ try { fs.unlinkSync(file); } catch { /* already gone */ }
57
+ if (cached.file === file) cached = { file: '', mtime: 0, data: null };
58
+ }
59
+
60
+ let cached = { file: '', mtime: 0, data: null };
61
+
62
+ export function loadSubscription(file = subscriptionFile()) {
63
+ const envKey = asKey(process.env.OPENZOO_SUBSCRIPTION_KEY);
64
+ if (envKey) {
65
+ return {
66
+ key: envKey,
67
+ tier: process.env.OPENZOO_SUBSCRIPTION_TIER || null,
68
+ tierName: process.env.OPENZOO_SUBSCRIPTION_TIER
69
+ ? titleCase(process.env.OPENZOO_SUBSCRIPTION_TIER)
70
+ : null,
71
+ sessionId: null,
72
+ source: 'env',
73
+ };
74
+ }
75
+ try {
76
+ const st = fs.statSync(file);
77
+ if (cached.file === file && cached.mtime === st.mtimeMs && cached.data) return cached.data;
78
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
79
+ if (!asKey(data?.key)) {
80
+ cached = { file, mtime: st.mtimeMs, data: null };
81
+ return null;
82
+ }
83
+ const rec = {
84
+ key: asKey(data.key),
85
+ tier: data.tier || null,
86
+ tierName: data.tierName || (data.tier ? titleCase(data.tier) : null),
87
+ sessionId: data.sessionId || null,
88
+ source: 'file',
89
+ };
90
+ cached = { file, mtime: st.mtimeMs, data: rec };
91
+ return rec;
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ /** Public HUD/wallet view — never includes the secret. */
98
+ export function subscriptionPublicView(sub = loadSubscription()) {
99
+ if (!asKey(sub?.key)) return { active: false };
100
+ const name = String(sub.tierName || titleCase(sub.tier) || '').trim();
101
+ return {
102
+ active: true,
103
+ tier: sub.tier || null,
104
+ tierName: name || null,
105
+ label: name ? `${name} · no x402` : 'Subscription key · no x402',
106
+ };
107
+ }
108
+
109
+ /**
110
+ * A paste is either the bearer key itself, or the site's success URL
111
+ * (`/billing/done?session=cs_…`). session_id is accepted too — Stripe's
112
+ * default query name — but the live page uses `session`.
113
+ */
114
+ export function parseSubscriptionPaste(text) {
115
+ const raw = String(text || '').trim();
116
+ if (!raw) return { error: 'empty' };
117
+ let session = '';
118
+ try {
119
+ if (/^https?:\/\//i.test(raw) || raw.includes('session=')) {
120
+ const url = new URL(raw, BILLING_ORIGIN);
121
+ session = url.searchParams.get('session') || url.searchParams.get('session_id') || '';
122
+ }
123
+ } catch { /* not a URL */ }
124
+ if (!session) {
125
+ const m = /(?:session_id|session)=([A-Za-z0-9_]+)/.exec(raw);
126
+ if (m) session = m[1];
127
+ }
128
+ if (session) return { session };
129
+ if (/^https?:\/\//i.test(raw)) return { error: 'no session in URL' };
130
+ if (raw.length < 8 || /\s/.test(raw)) return { error: 'not a key' };
131
+ return { key: raw };
132
+ }
133
+
134
+ export function applySubscriptionHeaders(headers = {}, sub = loadSubscription()) {
135
+ const key = asKey(sub?.key);
136
+ if (!key) return headers;
137
+ return { ...headers, authorization: `Bearer ${key}` };
138
+ }
139
+
140
+ export function stripAuthorization(headers = {}) {
141
+ const out = { ...headers };
142
+ delete out.authorization;
143
+ delete out.Authorization;
144
+ return out;
145
+ }
146
+
147
+ async function billingJson(url, init) {
148
+ const r = await fetch(url, init);
149
+ const body = await r.json().catch(() => ({}));
150
+ return { http: r.status, body };
151
+ }
152
+
153
+ export async function billingTiers() {
154
+ const { http, body } = await billingJson(`${BILLING_ORIGIN}/api/billing/tiers`);
155
+ if (!body?.ok || !Array.isArray(body.tiers)) {
156
+ throw new Error(body?.error || `tiers HTTP ${http}`);
157
+ }
158
+ return body;
159
+ }
160
+
161
+ export async function billingCheckout(tier) {
162
+ const id = String(tier || '').trim();
163
+ if (!id) throw new Error('tier required');
164
+ const { http, body } = await billingJson(`${BILLING_ORIGIN}/api/billing/checkout`, {
165
+ method: 'POST',
166
+ headers: { 'content-type': 'application/json' },
167
+ body: JSON.stringify({ tier: id }),
168
+ });
169
+ if (!body?.ok || !body.url) {
170
+ throw new Error(body?.error || `checkout HTTP ${http}`);
171
+ }
172
+ return { ok: true, url: body.url, sessionId: body.sessionId || null, tier: id };
173
+ }
174
+
175
+ /** Poll the same endpoint the public /billing/done page uses. */
176
+ export async function fetchBillingKey(session) {
177
+ const sid = String(session || '').trim();
178
+ if (!sid) return { ok: false, error: 'session required' };
179
+ const { body } = await billingJson(
180
+ `${BILLING_ORIGIN}/api/billing/key?session=${encodeURIComponent(sid)}`,
181
+ );
182
+ return body && typeof body === 'object' ? body : { ok: false, error: 'empty key response' };
183
+ }
184
+
185
+ /**
186
+ * If the live key endpoint returned a key, persist it and return a public
187
+ * view (the secret stays on disk). Pending/error bodies pass through.
188
+ */
189
+ export function ingestBillingKeyResponse(body, extra = {}, file = subscriptionFile()) {
190
+ const key = asKey(body?.key);
191
+ if (!key) {
192
+ if (body?.pending) return { ok: true, pending: true, saved: false };
193
+ return {
194
+ ok: false,
195
+ pending: false,
196
+ saved: false,
197
+ error: body?.error || 'no key yet',
198
+ };
199
+ }
200
+ const rec = saveSubscription({
201
+ key,
202
+ tier: body.tier || extra.tier || null,
203
+ tierName: body.tierName || body.name || extra.tierName || null,
204
+ sessionId: extra.sessionId || extra.session || null,
205
+ }, file);
206
+ return { ok: true, pending: false, saved: true, ...subscriptionPublicView(rec) };
207
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.48.89",
3
+ "version": "0.48.94",
4
4
  "description": "Local x402-paying proxy + MCP server for openzoo.fun \u2014 point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana and Base rails live; Robinhood experimental.",
5
5
  "license": "MIT",
6
6
  "type": "module",