openzoo 0.48.87 → 0.48.92

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.
@@ -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.87",
3
+ "version": "0.48.92",
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",