@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.
- package/dist/index.mjs +3278 -0
- package/dist/trigger-scan.mjs +95 -0
- package/package.json +40 -0
- package/src/auto-crud.ts +244 -0
- package/src/billing.ts +297 -0
- package/src/broker.ts +85 -0
- package/src/codec.ts +41 -0
- package/src/ctx.ts +33 -0
- package/src/db.ts +258 -0
- package/src/dispatcher.ts +257 -0
- package/src/http-error.ts +20 -0
- package/src/index.ts +702 -0
- package/src/migrator.ts +167 -0
- package/src/notifications.ts +628 -0
- package/src/predicate.ts +440 -0
- package/src/storage.ts +384 -0
- package/src/subscriptions.ts +654 -0
- package/src/test/auto-crud.test.ts +385 -0
- package/src/test/dispatcher.test.ts +271 -0
- package/src/test/migrator.test.ts +166 -0
- package/src/test/notifications.test.ts +96 -0
- package/src/test/predicate.test.ts +267 -0
- package/src/test/schema-swap.test.ts +252 -0
- package/src/test/security.test.ts +323 -0
- package/src/test/subscriptions.test.ts +878 -0
- package/src/test/trigger-scan.test.ts +78 -0
- package/src/trigger-scan.ts +173 -0
- package/src/triggers.ts +837 -0
- package/src/web-push.d.ts +18 -0
- package/src/workflows.test.ts +127 -0
- package/src/workflows.ts +438 -0
|
@@ -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
|
+
}
|
package/src/auto-crud.ts
ADDED
|
@@ -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
|