@omg-dev/server 0.4.24

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,95 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs";
3
+ //#region src/trigger-scan.ts
4
+ /** scanTriggers walks `<root>/functions/` and returns every detected trigger. */
5
+ async function scanTriggers(root) {
6
+ const fnDir = path.join(root, "functions");
7
+ if (!fs.existsSync(fnDir)) return [];
8
+ const out = [];
9
+ walk(fnDir, out);
10
+ const apiDir = path.join(fnDir, "api");
11
+ if (fs.existsSync(apiDir) && fs.statSync(apiDir).isDirectory()) walk(apiDir, out);
12
+ return out;
13
+ }
14
+ function walk(dir, out) {
15
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
16
+ if (entry.isDirectory()) continue;
17
+ if (!entry.name.endsWith(".ts") || entry.name.endsWith(".d.ts")) continue;
18
+ const filePath = path.join(dir, entry.name);
19
+ const source = fs.readFileSync(filePath, "utf-8");
20
+ const basename = path.basename(entry.name, ".ts");
21
+ out.push(...extractTriggers(source, filePath, basename));
22
+ }
23
+ }
24
+ const EXPORT_TRIGGER_RE = /^export\s+const\s+(\w+)\s*(?::\s*[^=]+)?\s*=\s*(cron|on)\s*\(\s*(.+?)\s*,/gm;
25
+ const EXPORT_STORAGE_HOOK_RE = /^export\s+const\s+(\w+)\s*(?::\s*[^=]+)?\s*=\s*storage\.(onUpload|onDelete)\s*\(/gm;
26
+ var TriggerScanError = class extends Error {
27
+ constructor(file, detail) {
28
+ super(`[vibes:triggers] ${file}: ${detail}`);
29
+ this.file = file;
30
+ this.detail = detail;
31
+ this.name = "TriggerScanError";
32
+ }
33
+ };
34
+ function extractTriggers(source, filePath, basename) {
35
+ const out = [];
36
+ const stripped = source.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
37
+ let m;
38
+ EXPORT_TRIGGER_RE.lastIndex = 0;
39
+ while ((m = EXPORT_TRIGGER_RE.exec(stripped)) !== null) {
40
+ const [, exportName, kind, firstArg] = m;
41
+ const lit = parseStringLiteral(firstArg);
42
+ if (lit === null) throw new TriggerScanError(filePath, `${kind}() first arg must be a string literal — got: ${firstArg.slice(0, 60)}`);
43
+ out.push({
44
+ handler: `${basename}.${exportName}`,
45
+ kind,
46
+ key: lit,
47
+ module: filePath,
48
+ exportName
49
+ });
50
+ }
51
+ EXPORT_STORAGE_HOOK_RE.lastIndex = 0;
52
+ while ((m = EXPORT_STORAGE_HOOK_RE.exec(stripped)) !== null) {
53
+ const [, exportName, hookName] = m;
54
+ out.push({
55
+ handler: `${basename}.${exportName}`,
56
+ kind: hookName === "onUpload" ? "storage:upload" : "storage:delete",
57
+ key: "",
58
+ module: filePath,
59
+ exportName
60
+ });
61
+ }
62
+ return out;
63
+ }
64
+ /**
65
+ * Parse a single-line first-arg into a string literal. Returns null when the
66
+ * input isn't a plain "..." or '...' literal (e.g. it's a template literal, a
67
+ * variable reference, or a runtime expression).
68
+ */
69
+ function parseStringLiteral(raw) {
70
+ const trimmed = raw.trim();
71
+ if (trimmed.length < 2) return null;
72
+ const first = trimmed[0];
73
+ if ((first === "\"" || first === "'") && trimmed.endsWith(first)) {
74
+ const inner = trimmed.slice(1, -1);
75
+ if (inner.includes("${")) return null;
76
+ return inner.replace(/\\(.)/g, "$1");
77
+ }
78
+ return null;
79
+ }
80
+ /**
81
+ * Scan `<root>/functions/` and write the result to `<root>/.vibes/triggers.json`
82
+ * — the file `createVibesServer` loads at boot when no bundled `triggers`
83
+ * array is passed. For hosts without a vite build (control-plane container,
84
+ * self-host bundles): call this before `createVibesServer` so cron()/on()
85
+ * declarations in functions/*.ts actually register.
86
+ */
87
+ async function writeTriggersManifest(root) {
88
+ const triggers = await scanTriggers(root);
89
+ const vibesDir = path.join(root, ".vibes");
90
+ fs.mkdirSync(vibesDir, { recursive: true });
91
+ fs.writeFileSync(path.join(vibesDir, "triggers.json"), JSON.stringify(triggers, null, 2), "utf-8");
92
+ return triggers;
93
+ }
94
+ //#endregion
95
+ export { TriggerScanError, extractTriggers, scanTriggers, writeTriggersManifest };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@omg-dev/server",
3
+ "version": "0.4.24",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "types": "./src/index.ts",
8
+ "default": "./dist/index.mjs"
9
+ },
10
+ "./trigger-scan": {
11
+ "types": "./src/trigger-scan.ts",
12
+ "default": "./dist/trigger-scan.mjs"
13
+ }
14
+ },
15
+ "dependencies": {
16
+ "@restatedev/restate-sdk": "1.14.5",
17
+ "@omg-dev/auth": "0.4.24",
18
+ "@omg-dev/schema": "0.4.24",
19
+ "@omg-dev/stream": "0.4.24",
20
+ "web-push": "^3.6.7"
21
+ },
22
+ "scripts": {
23
+ "build": "vp pack src/index.ts src/trigger-scan.ts",
24
+ "test": "vp test run"
25
+ },
26
+ "license": "MIT",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/BennyKok/vibes.git"
30
+ },
31
+ "homepage": "https://docs.omg.dev",
32
+ "files": [
33
+ "dist",
34
+ "src"
35
+ ],
36
+ "publishConfig": {
37
+ "access": "public",
38
+ "registry": "https://registry.npmjs.org/"
39
+ }
40
+ }
@@ -0,0 +1,244 @@
1
+ // auto-crud — schema-derived REST routes mounted at /api/<collection>.
2
+ //
3
+ // Generates the five conventional verbs (list/get/create/update/remove) for
4
+ // every collection in the Schema so SDK callers like `useCollection` work
5
+ // out-of-the-box without a `functions/<collection>.ts` file.
6
+ //
7
+ // User-scoped collections (`scope: "user"`) get row-level filtering by the
8
+ // `_owner` column, sourced from ctxStore. We treat "row not found" and "row
9
+ // owned by someone else" the same (404) to avoid leaking row existence.
10
+
11
+ import type { Schema, FieldType, CollectionConfig } from "@omg-dev/schema"
12
+ import { getDbInstance } from "./db.ts"
13
+ import { invalidate } from "./broker.ts"
14
+ import { notifyRowChange } from "./subscriptions.ts"
15
+ import { ctxStore } from "./ctx.ts"
16
+ import type { Route } from "./dispatcher.ts"
17
+ import { encodeValue, decodeRow, decodeRows } from "./codec.ts"
18
+
19
+ type InlineHandler = (req: Request, params: Record<string, string>) => Promise<Response>
20
+
21
+ // ── Body parsing ──────────────────────────────────────────────────────────────
22
+
23
+ async function readJsonBody(req: Request): Promise<Record<string, unknown>> {
24
+ const ct = req.headers.get("content-type") ?? ""
25
+ if (!ct.includes("application/json")) return {}
26
+ const text = await req.text()
27
+ if (!text.trim()) return {}
28
+ try {
29
+ const parsed = JSON.parse(text)
30
+ return (parsed && typeof parsed === "object") ? parsed as Record<string, unknown> : {}
31
+ } catch {
32
+ throw new Error("Invalid JSON body")
33
+ }
34
+ }
35
+
36
+ function jsonError(status: number, message: string): Response {
37
+ return Response.json({ error: message }, { status })
38
+ }
39
+
40
+ // ── Handlers ──────────────────────────────────────────────────────────────────
41
+
42
+ function getUserId(): string | null {
43
+ return ctxStore.getStore()?.userId ?? null
44
+ }
45
+
46
+ function makeListHandler(
47
+ name: string,
48
+ fields: Record<string, { type: FieldType }>,
49
+ scoped: boolean,
50
+ ): InlineHandler {
51
+ return async () => {
52
+ const db = getDbInstance()
53
+ if (!db) return jsonError(500, "db not initialized")
54
+
55
+ if (scoped) {
56
+ const userId = getUserId()
57
+ if (!userId) return jsonError(401, "Authentication required")
58
+ const rows = db.raw()
59
+ .prepare(`SELECT * FROM ${name} WHERE _owner = ? ORDER BY created_at DESC`)
60
+ .all(userId) as Record<string, unknown>[]
61
+ return Response.json(decodeRows(rows, fields))
62
+ }
63
+
64
+ const rows = db.raw()
65
+ .prepare(`SELECT * FROM ${name} ORDER BY created_at DESC`)
66
+ .all() as Record<string, unknown>[]
67
+ return Response.json(decodeRows(rows, fields))
68
+ }
69
+ }
70
+
71
+ function makeGetHandler(
72
+ name: string,
73
+ fields: Record<string, { type: FieldType }>,
74
+ scoped: boolean,
75
+ ): InlineHandler {
76
+ return async (_req, params) => {
77
+ const db = getDbInstance()
78
+ if (!db) return jsonError(500, "db not initialized")
79
+
80
+ if (scoped) {
81
+ const userId = getUserId()
82
+ if (!userId) return jsonError(401, "Authentication required")
83
+ // Treat "no row" and "wrong owner" identically — leaking 404 vs 403
84
+ // would let an attacker enumerate other users' ids.
85
+ const row = db.raw()
86
+ .prepare(`SELECT * FROM ${name} WHERE id = ? AND _owner = ? LIMIT 1`)
87
+ .get(params.id, userId) as Record<string, unknown> | null
88
+ if (!row) return jsonError(404, "Not found")
89
+ return Response.json(decodeRow(row, fields))
90
+ }
91
+
92
+ const row = db.raw().prepare(`SELECT * FROM ${name} WHERE id = ? LIMIT 1`).get(params.id) as
93
+ | Record<string, unknown>
94
+ | null
95
+ if (!row) return jsonError(404, "Not found")
96
+ return Response.json(decodeRow(row, fields))
97
+ }
98
+ }
99
+
100
+ function makeCreateHandler(
101
+ name: string,
102
+ fields: Record<string, { type: FieldType }>,
103
+ scoped: boolean,
104
+ ): InlineHandler {
105
+ return async (req) => {
106
+ const db = getDbInstance()
107
+ if (!db) return jsonError(500, "db not initialized")
108
+
109
+ let userId: string | null = null
110
+ if (scoped) {
111
+ userId = getUserId()
112
+ if (!userId) return jsonError(401, "Authentication required")
113
+ }
114
+
115
+ let body: Record<string, unknown>
116
+ try { body = await readJsonBody(req) } catch (e) {
117
+ return jsonError(400, (e as Error).message)
118
+ }
119
+
120
+ const id = crypto.randomUUID()
121
+ const ts = new Date().toISOString()
122
+ const record: Record<string, unknown> = { id, created_at: ts, updated_at: ts }
123
+ if (scoped) record._owner = userId
124
+ for (const [k, def] of Object.entries(fields)) {
125
+ if (k in body) record[k] = encodeValue(def.type, body[k])
126
+ }
127
+
128
+ const cols = Object.keys(record)
129
+ const placeholders = cols.map(() => "?").join(", ")
130
+ const values = Object.values(record)
131
+ db.raw().prepare(`INSERT INTO ${name} (${cols.join(", ")}) VALUES (${placeholders})`).run(...values)
132
+
133
+ invalidate(name)
134
+ void notifyRowChange(name, "insert", null, record)
135
+ return Response.json(decodeRow(record, fields))
136
+ }
137
+ }
138
+
139
+ function makeUpdateHandler(
140
+ name: string,
141
+ fields: Record<string, { type: FieldType }>,
142
+ scoped: boolean,
143
+ ): InlineHandler {
144
+ return async (req, params) => {
145
+ const db = getDbInstance()
146
+ if (!db) return jsonError(500, "db not initialized")
147
+
148
+ let userId: string | null = null
149
+ if (scoped) {
150
+ userId = getUserId()
151
+ if (!userId) return jsonError(401, "Authentication required")
152
+ }
153
+
154
+ let body: Record<string, unknown>
155
+ try { body = await readJsonBody(req) } catch (e) {
156
+ return jsonError(400, (e as Error).message)
157
+ }
158
+
159
+ const existing = db.raw().prepare(`SELECT * FROM ${name} WHERE id = ? LIMIT 1`).get(params.id) as
160
+ | Record<string, unknown>
161
+ | null
162
+ if (!existing) return jsonError(404, "Not found")
163
+ if (scoped && existing._owner !== userId) return jsonError(404, "Not found")
164
+
165
+ const updates: Record<string, unknown> = {}
166
+ for (const [k, v] of Object.entries(body)) {
167
+ if (k === "id" || k === "created_at" || k === "updated_at" || k === "_owner") continue
168
+ if (!(k in fields)) return jsonError(400, `Unknown column: ${k}`)
169
+ updates[k] = encodeValue(fields[k].type, v)
170
+ }
171
+ updates.updated_at = new Date().toISOString()
172
+
173
+ const setClauses = Object.keys(updates).map(k => `${k} = ?`).join(", ")
174
+ const values = [...Object.values(updates), params.id]
175
+ db.raw().prepare(`UPDATE ${name} SET ${setClauses} WHERE id = ?`).run(...values)
176
+
177
+ const merged = { ...existing, ...updates }
178
+ invalidate(name)
179
+ void notifyRowChange(name, "update", existing, merged)
180
+ return Response.json(decodeRow(merged, fields))
181
+ }
182
+ }
183
+
184
+ function makeRemoveHandler(name: string, scoped: boolean): InlineHandler {
185
+ return async (_req, params) => {
186
+ const db = getDbInstance()
187
+ if (!db) return jsonError(500, "db not initialized")
188
+
189
+ // Always SELECT the full row first — even for global tables. Delta
190
+ // subscribers running a predicate need the pre-delete row to decide
191
+ // whether the delete affects their read set (was-in vs was-out
192
+ // matters even when no scope check is needed).
193
+ const existing = db.raw()
194
+ .prepare(`SELECT * FROM ${name} WHERE id = ? LIMIT 1`)
195
+ .get(params.id) as Record<string, unknown> | null
196
+ if (!existing) return jsonError(404, "Not found")
197
+ if (scoped) {
198
+ const userId = getUserId()
199
+ if (!userId) return jsonError(401, "Authentication required")
200
+ if (existing._owner !== userId) return jsonError(404, "Not found")
201
+ }
202
+
203
+ db.raw().prepare(`DELETE FROM ${name} WHERE id = ?`).run(params.id)
204
+ invalidate(name)
205
+ void notifyRowChange(name, "delete", existing, null)
206
+ return new Response(null, { status: 204 })
207
+ }
208
+ }
209
+
210
+ // ── buildAutoCrudRoutes ───────────────────────────────────────────────────────
211
+
212
+ export function buildAutoCrudRoutes(schema: Schema): Route[] {
213
+ const routes: Route[] = []
214
+ for (const [name, col] of Object.entries(schema.collections)) {
215
+ const fields = col.fields
216
+ const scoped = (col as CollectionConfig).scope === "user"
217
+ routes.push({
218
+ method: "GET", path: `/api/${name}`,
219
+ module: "<auto-crud>", handler: "list",
220
+ inlineHandler: makeListHandler(name, fields, scoped),
221
+ })
222
+ routes.push({
223
+ method: "GET", path: `/api/${name}/:id`,
224
+ module: "<auto-crud>", handler: "get",
225
+ inlineHandler: makeGetHandler(name, fields, scoped),
226
+ })
227
+ routes.push({
228
+ method: "POST", path: `/api/${name}`,
229
+ module: "<auto-crud>", handler: "create",
230
+ inlineHandler: makeCreateHandler(name, fields, scoped),
231
+ })
232
+ routes.push({
233
+ method: "PATCH", path: `/api/${name}/:id`,
234
+ module: "<auto-crud>", handler: "update",
235
+ inlineHandler: makeUpdateHandler(name, fields, scoped),
236
+ })
237
+ routes.push({
238
+ method: "DELETE", path: `/api/${name}/:id`,
239
+ module: "<auto-crud>", handler: "remove",
240
+ inlineHandler: makeRemoveHandler(name, scoped),
241
+ })
242
+ }
243
+ return routes
244
+ }
package/src/billing.ts ADDED
@@ -0,0 +1,297 @@
1
+ // @omg-dev/server — per-app billing runtime client.
2
+ //
3
+ // A deployed user app imports `billing` to meter + gate entitlements for its
4
+ // OWN end-users (the app's customers, keyed by an app-chosen `externalRef`).
5
+ // This is distinct from omg's tenant-zero billing of the app owner.
6
+ //
7
+ // Transport mirrors emit()/storage: every call POSTs (or GETs) the in-VM
8
+ // agent at http://localhost:8080/_billing/*. The agent forwards to the Go
9
+ // billing engine (infra), scoped to this app's `app_id`. The app never talks
10
+ // to infra directly — the agent injects identity, so the runtime client only
11
+ // carries the business-level fields.
12
+ //
13
+ // Money is integer MICRO-UNITS of the app's credit unit (matching the Go
14
+ // engine + @omg-dev/billing): usd(1) === 1_000_000. Never pass a float balance.
15
+ //
16
+ // ── Usage: gate a feature in a route ─────────────────────────────────────────
17
+ //
18
+ // import { billing, usd, type Route } from "@omg-dev/server"
19
+ //
20
+ // // On signup: create the customer (idempotent on userId).
21
+ // export const onSignup: Route = async (ctx) => {
22
+ // await billing.ensureCustomer(ctx.user.id, "free")
23
+ // return Response.json({ ok: true })
24
+ // }
25
+ //
26
+ // // Before serving a metered feature: check entitlement, then track usage.
27
+ // export const generate: Route = async (ctx) => {
28
+ // const gate = await billing.check("ai_generation", ctx.user.id)
29
+ // if (!gate.allow) {
30
+ // return Response.json({ error: gate.reason ?? "out of credits" }, { status: 402 })
31
+ // }
32
+ // const result = await doExpensiveWork()
33
+ // await billing.track("ai_generation", ctx.user.id, usd(0.01), ctx.requestId)
34
+ // return Response.json(result)
35
+ // }
36
+
37
+ const AGENT_BASE = "http://localhost:8080"
38
+ const MICROS_PER_UNIT = 1_000_000
39
+
40
+ /**
41
+ * Dollars (or whole credit units) → integer micro-units, for the amount
42
+ * arguments of `track`/`grant`. `usd(5)` → 5_000_000. Re-exported from the
43
+ * same definition @omg-dev/billing uses so app authors can write `usd(5)`.
44
+ */
45
+ export function usd(amount: number): number {
46
+ return Math.round(amount * MICROS_PER_UNIT)
47
+ }
48
+
49
+ // ── Contract types (mirror the agent /_billing/* endpoints) ──────────────────
50
+
51
+ export interface EnsureCustomerResult {
52
+ customerId: string
53
+ plan: string | null
54
+ created: boolean
55
+ }
56
+
57
+ export interface CheckResult {
58
+ allow: boolean
59
+ balanceMicros: number
60
+ plan: string | null
61
+ reason?: string
62
+ }
63
+
64
+ export interface TrackResult {
65
+ balanceMicros: number
66
+ }
67
+
68
+ export interface GrantResult {
69
+ balanceMicros: number
70
+ }
71
+
72
+ export interface BalanceResult {
73
+ exists: boolean
74
+ plan: string | null
75
+ balanceMicros: number
76
+ }
77
+
78
+ /** A single subscription record for an end-user (newest by update time). */
79
+ export interface SubscriptionRecord {
80
+ status: string
81
+ planVersion: number
82
+ currentPeriodStart: number
83
+ currentPeriodEnd: number
84
+ providerSubscriptionId: string
85
+ }
86
+
87
+ /**
88
+ * The end-user's current entitlement: the plan they're on plus their newest
89
+ * subscription row (null when they have none / never subscribed). Drive
90
+ * <PricingTable currentPlan={plan} /> and "You're on Pro" gates from this.
91
+ */
92
+ export interface SubscriptionResult {
93
+ plan: string | null
94
+ subscription: SubscriptionRecord | null
95
+ }
96
+
97
+ /** A hosted-checkout the app's end-user is redirected to in order to pay. */
98
+ export interface CheckoutResult {
99
+ /** Provider-hosted checkout URL — redirect the end-user here. */
100
+ checkoutUrl: string
101
+ /** Provider checkout id (for correlation / status lookups). */
102
+ checkoutId: string
103
+ }
104
+
105
+ export interface CheckoutOptions {
106
+ /**
107
+ * The plan key (from your @omg-dev/billing catalog) the end-user is buying.
108
+ * The owner's Polar product + the charge amount are resolved SERVER-SIDE
109
+ * from the active plan-version for this key — the client never supplies the
110
+ * price, so a tampered client can't change what it's charged.
111
+ */
112
+ plan: string
113
+ /** The app's end-user id — the billing customer credited on payment. */
114
+ externalRef: string
115
+ /**
116
+ * Optional override for the post-payment redirect. Defaults to the omg
117
+ * checkout-complete page.
118
+ */
119
+ successUrl?: string
120
+ /**
121
+ * Optional feature/credit bucket + amount override for a one-time top-up
122
+ * style checkout. For a known PLAN these are derived server-side from the
123
+ * plan-version (price) + catalog (feature) and any client value is ignored;
124
+ * supply them only for ad-hoc credit purchases. Amount is in micro-units.
125
+ */
126
+ feature?: string
127
+ amountMicros?: number
128
+ }
129
+
130
+ export interface GrantOptions {
131
+ idempotencyKey: string
132
+ grantKey?: string
133
+ source?: string
134
+ reason?: string
135
+ }
136
+
137
+ // ── Transport ────────────────────────────────────────────────────────────────
138
+ //
139
+ // Shared POST helper, same shape as storage.ts's prod calls: JSON in, JSON
140
+ // out, throw on non-2xx with a truncated body for diagnosability.
141
+
142
+ async function agentPost<T>(path: string, body: unknown, label: string): Promise<T> {
143
+ const res = await fetch(`${AGENT_BASE}${path}`, {
144
+ method: "POST",
145
+ headers: { "Content-Type": "application/json" },
146
+ body: JSON.stringify(body),
147
+ })
148
+ if (!res.ok) {
149
+ const text = await res.text().catch(() => "")
150
+ throw new Error(`billing.${label} agent ${res.status}: ${text.slice(0, 200)}`)
151
+ }
152
+ return (await res.json()) as T
153
+ }
154
+
155
+ async function agentGet<T>(path: string, label: string): Promise<T> {
156
+ const res = await fetch(`${AGENT_BASE}${path}`, {
157
+ method: "GET",
158
+ headers: { Accept: "application/json" },
159
+ })
160
+ if (!res.ok) {
161
+ const text = await res.text().catch(() => "")
162
+ throw new Error(`billing.${label} agent ${res.status}: ${text.slice(0, 200)}`)
163
+ }
164
+ return (await res.json()) as T
165
+ }
166
+
167
+ // ── Public client ────────────────────────────────────────────────────────────
168
+
169
+ export const billing = {
170
+ /**
171
+ * Create (or fetch) the billing customer for one of this app's end-users.
172
+ * Idempotent on `externalRef` within the app — call it on signup. `plan`
173
+ * optionally pins the customer onto a declared plan at creation.
174
+ */
175
+ async ensureCustomer(externalRef: string, plan?: string): Promise<EnsureCustomerResult> {
176
+ return agentPost<EnsureCustomerResult>(
177
+ "/_billing/customers",
178
+ { externalRef, plan },
179
+ "ensureCustomer",
180
+ )
181
+ },
182
+
183
+ /**
184
+ * Check whether `externalRef` is entitled to use `feature` right now. Read
185
+ * the `allow` flag before serving the feature; `reason` explains a denial.
186
+ */
187
+ async check(feature: string, externalRef: string): Promise<CheckResult> {
188
+ return agentPost<CheckResult>(
189
+ "/_billing/check",
190
+ { feature, externalRef },
191
+ "check",
192
+ )
193
+ },
194
+
195
+ /**
196
+ * Record `amountMicros` of `feature` usage against `externalRef` after the
197
+ * feature was served. `idempotencyKey` dedupes retries — reuse the same key
198
+ * for the same logical unit of work. Returns the new balance.
199
+ */
200
+ async track(
201
+ feature: string,
202
+ externalRef: string,
203
+ amountMicros: number,
204
+ idempotencyKey: string,
205
+ reason?: string,
206
+ ): Promise<TrackResult> {
207
+ return agentPost<TrackResult>(
208
+ "/_billing/track",
209
+ { feature, externalRef, amountMicros, idempotencyKey, reason },
210
+ "track",
211
+ )
212
+ },
213
+
214
+ /**
215
+ * Add `amountMicros` of `feature` credit to `externalRef` (a top-up, refund,
216
+ * or promo grant). `opts.idempotencyKey` dedupes retries; `opts.grantKey`
217
+ * dedupes a one-time grant (e.g. a signup bonus) across the customer's
218
+ * lifetime. Returns the new balance.
219
+ */
220
+ async grant(
221
+ externalRef: string,
222
+ feature: string,
223
+ amountMicros: number,
224
+ opts: GrantOptions,
225
+ ): Promise<GrantResult> {
226
+ return agentPost<GrantResult>(
227
+ "/_billing/grant",
228
+ {
229
+ externalRef,
230
+ feature,
231
+ amountMicros,
232
+ idempotencyKey: opts.idempotencyKey,
233
+ grantKey: opts.grantKey,
234
+ source: opts.source,
235
+ reason: opts.reason,
236
+ },
237
+ "grant",
238
+ )
239
+ },
240
+
241
+ /**
242
+ * Read the current `feature` balance for `externalRef` without mutating it.
243
+ * `exists` is false if the customer was never created.
244
+ */
245
+ async balance(externalRef: string, feature: string): Promise<BalanceResult> {
246
+ const qs = new URLSearchParams({ externalRef, feature })
247
+ return agentGet<BalanceResult>(
248
+ `/_billing/balance?${qs.toString()}`,
249
+ "balance",
250
+ )
251
+ },
252
+
253
+ /**
254
+ * Read `externalRef`'s current plan + newest subscription record without
255
+ * mutating anything. Use it to render entitlement UI — pass `plan` to
256
+ * <PricingTable currentPlan> or gate paid features. An end-user who never
257
+ * subscribed reads back the catalog default plan with `subscription: null`.
258
+ */
259
+ async subscription(externalRef: string): Promise<SubscriptionResult> {
260
+ const qs = new URLSearchParams({ externalRef })
261
+ return agentGet<SubscriptionResult>(
262
+ `/_billing/subscription?${qs.toString()}`,
263
+ "subscription",
264
+ )
265
+ },
266
+
267
+ /**
268
+ * Mint a hosted checkout for one of this app's end-users to buy `plan`, in
269
+ * the APP OWNER's connected Polar org. Returns the checkout URL to redirect
270
+ * to. The amount + product are resolved server-side from the active
271
+ * plan-version (the client cannot set the price). On successful payment the
272
+ * Polar webhook credits/upgrades `externalRef` automatically.
273
+ *
274
+ * export const buyPro: Route = async (ctx) => {
275
+ * const { checkoutUrl } = await billing.checkout({
276
+ * plan: "pro",
277
+ * externalRef: ctx.user.id,
278
+ * })
279
+ * return Response.redirect(checkoutUrl, 303)
280
+ * }
281
+ */
282
+ async checkout(opts: CheckoutOptions): Promise<CheckoutResult> {
283
+ return agentPost<CheckoutResult>(
284
+ "/_billing/checkout",
285
+ {
286
+ plan: opts.plan,
287
+ externalRef: opts.externalRef,
288
+ successUrl: opts.successUrl,
289
+ feature: opts.feature,
290
+ amountMicros: opts.amountMicros,
291
+ },
292
+ "checkout",
293
+ )
294
+ },
295
+ }
296
+
297
+ export type Billing = typeof billing