@sorb/juice 0.1.0

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.js ADDED
@@ -0,0 +1,989 @@
1
+ const __sorbModuleUrl = require('url').pathToFileURL(require('path').join(__dirname, 'migrate.js')).href;
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __esm = (fn, res) => function __init() {
9
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
10
+ };
11
+ var __export = (target, all) => {
12
+ for (var name in all)
13
+ __defProp(target, name, { get: all[name], enumerable: true });
14
+ };
15
+ var __copyProps = (to, from, except, desc) => {
16
+ if (from && typeof from === "object" || typeof from === "function") {
17
+ for (let key of __getOwnPropNames(from))
18
+ if (!__hasOwnProp.call(to, key) && key !== except)
19
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
20
+ }
21
+ return to;
22
+ };
23
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
24
+ // If the importer is in node compatibility mode or this is not an ESM
25
+ // file that has been converted to a CommonJS file using a Babel-
26
+ // compatible transform (i.e. "__esModule" has not been set), then set
27
+ // "default" to the CommonJS "module.exports" for node compatibility.
28
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
29
+ mod
30
+ ));
31
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
32
+
33
+ // src/store/redis.js
34
+ var redis_exports = {};
35
+ __export(redis_exports, {
36
+ createRedisStore: () => createRedisStore,
37
+ default: () => redis_default
38
+ });
39
+ var import_ioredis, PREVIEW_PREFIX, VERIFY_PREFIX, VERIFY_LATEST_KEY, previewKey, verifyKey, scanCount, createRedisStore, redis_default;
40
+ var init_redis = __esm({
41
+ "src/store/redis.js"() {
42
+ import_ioredis = __toESM(require("ioredis"));
43
+ PREVIEW_PREFIX = "preview:";
44
+ VERIFY_PREFIX = "verify:";
45
+ VERIFY_LATEST_KEY = "verify:latest";
46
+ previewKey = (id) => PREVIEW_PREFIX + id;
47
+ verifyKey = (id) => VERIFY_PREFIX + id;
48
+ scanCount = async (client, match) => {
49
+ let cursor = "0";
50
+ let total = 0;
51
+ do {
52
+ const [next, keys] = await client.scan(cursor, "MATCH", match, "COUNT", 100);
53
+ cursor = next;
54
+ total += keys.length;
55
+ } while (cursor !== "0");
56
+ return total;
57
+ };
58
+ createRedisStore = async (config) => {
59
+ const ttlMs = config.previewTtlMs;
60
+ const client = new import_ioredis.default(config.redisUrl, {
61
+ lazyConnect: true,
62
+ maxRetriesPerRequest: 2
63
+ });
64
+ client.on("error", () => {
65
+ });
66
+ try {
67
+ await client.connect();
68
+ } catch (e) {
69
+ }
70
+ const putPreview = async (id, tokens) => {
71
+ const entry = { tokens, createdAt: Date.now() };
72
+ await client.set(previewKey(id), JSON.stringify(entry), "PX", ttlMs);
73
+ };
74
+ const getPreview = async (id) => {
75
+ const raw = await client.get(previewKey(id));
76
+ if (raw == null) return null;
77
+ return JSON.parse(raw);
78
+ };
79
+ const hasPreview = async (id) => {
80
+ return await client.exists(previewKey(id)) === 1;
81
+ };
82
+ const updatePreview = async (id, tokens) => {
83
+ if (!await hasPreview(id)) return false;
84
+ const entry = { tokens, createdAt: Date.now() };
85
+ await client.set(previewKey(id), JSON.stringify(entry), "PX", ttlMs);
86
+ return true;
87
+ };
88
+ const deletePreview = async (id) => {
89
+ await client.del(previewKey(id));
90
+ };
91
+ const countPreviews = async () => {
92
+ return scanCount(client, PREVIEW_PREFIX + "*");
93
+ };
94
+ const putVerification = async (id, { storyId, bbox, meta }) => {
95
+ const entry = { storyId, bbox, meta, createdAt: Date.now() };
96
+ const pipeline = client.multi();
97
+ pipeline.set(verifyKey(id), JSON.stringify(entry), "PX", ttlMs);
98
+ pipeline.set(VERIFY_LATEST_KEY, id, "PX", ttlMs);
99
+ await pipeline.exec();
100
+ };
101
+ const getVerification = async (id) => {
102
+ const raw = await client.get(verifyKey(id));
103
+ if (raw == null) return null;
104
+ return JSON.parse(raw);
105
+ };
106
+ const getLatestVerification = async () => {
107
+ const id = await client.get(VERIFY_LATEST_KEY);
108
+ if (id == null) return null;
109
+ return getVerification(id);
110
+ };
111
+ const countVerifications = async () => {
112
+ const total = await scanCount(client, VERIFY_PREFIX + "*");
113
+ const hasLatest = await client.exists(VERIFY_LATEST_KEY) === 1;
114
+ return hasLatest ? Math.max(0, total - 1) : total;
115
+ };
116
+ const ping = async () => {
117
+ try {
118
+ return await client.ping() === "PONG";
119
+ } catch (e) {
120
+ return false;
121
+ }
122
+ };
123
+ const close = async () => {
124
+ try {
125
+ await client.quit();
126
+ } catch (e) {
127
+ }
128
+ };
129
+ return {
130
+ putPreview,
131
+ getPreview,
132
+ hasPreview,
133
+ updatePreview,
134
+ deletePreview,
135
+ countPreviews,
136
+ putVerification,
137
+ getVerification,
138
+ getLatestVerification,
139
+ countVerifications,
140
+ ping,
141
+ close
142
+ };
143
+ };
144
+ redis_default = createRedisStore;
145
+ }
146
+ });
147
+
148
+ // src/index.js
149
+ var src_exports = {};
150
+ __export(src_exports, {
151
+ createDb: () => createDb,
152
+ createMemoryStore: () => createMemoryStore,
153
+ createServer: () => createServer,
154
+ createStore: () => createStore,
155
+ loadConfig: () => loadConfig,
156
+ openTokenPR: () => openTokenPR,
157
+ runStyleDictionary: () => runStyleDictionary,
158
+ watchTokenFile: () => watchTokenFile
159
+ });
160
+ module.exports = __toCommonJS(src_exports);
161
+
162
+ // src/server.js
163
+ var import_hono = require("hono");
164
+ var import_cors = require("hono/cors");
165
+ var import_nanoid = require("nanoid");
166
+
167
+ // src/auth.js
168
+ var import_node_crypto = require("node:crypto");
169
+ var SCOPE = Object.freeze({ READ: "read", WRITE: "write" });
170
+ function hashKey(raw) {
171
+ return (0, import_node_crypto.createHash)("sha256").update(raw, "utf8").digest("hex");
172
+ }
173
+ function parseBearer(authHeader) {
174
+ if (typeof authHeader !== "string") return null;
175
+ const trimmed = authHeader.trim();
176
+ if (trimmed === "") return null;
177
+ const match = /^(\S+)\s+(.*)$/.exec(trimmed);
178
+ if (!match) return null;
179
+ const scheme = match[1];
180
+ if (scheme.toLowerCase() !== "bearer") return null;
181
+ const token = match[2].trim();
182
+ if (token === "") return null;
183
+ return token;
184
+ }
185
+ async function resolveApiKey(db, authHeader) {
186
+ const raw = parseBearer(authHeader);
187
+ if (raw === null) return null;
188
+ const hash = hashKey(raw);
189
+ const result = await db.query(
190
+ `SELECT k.id AS key_id, k.type, k.project_id, p.org_id, p.namespace, p.allowed_origins
191
+ FROM api_keys k
192
+ JOIN projects p ON p.id = k.project_id
193
+ WHERE k.hash = $1 AND k.revoked_at IS NULL
194
+ LIMIT 1`,
195
+ [hash]
196
+ );
197
+ const rows = result && result.rows || [];
198
+ if (rows.length === 0) return null;
199
+ const row = rows[0];
200
+ const type = row.type;
201
+ const scope = type === "publishable" ? SCOPE.READ : SCOPE.WRITE;
202
+ const allowedOrigins = Array.isArray(row.allowed_origins) ? row.allowed_origins : [];
203
+ return Object.freeze({
204
+ keyId: row.key_id,
205
+ type,
206
+ projectId: row.project_id,
207
+ orgId: row.org_id,
208
+ namespace: row.namespace,
209
+ allowedOrigins,
210
+ scope
211
+ });
212
+ }
213
+
214
+ // src/entitlements.js
215
+ var FREE = Object.freeze({
216
+ plan: "free",
217
+ status: "active",
218
+ seats: 1,
219
+ previewPersistence: false,
220
+ previewSharing: false,
221
+ maxProjects: 1,
222
+ maxActivePreviews: -1,
223
+ captureEnabled: false
224
+ });
225
+ var numOr = (v, fallback) => {
226
+ const n = Number(v);
227
+ return Number.isFinite(n) ? n : fallback;
228
+ };
229
+ function normalizeEntitlements(row) {
230
+ const r = row || {};
231
+ let data = r.data;
232
+ if (typeof data === "string") {
233
+ try {
234
+ data = JSON.parse(data);
235
+ } catch (e) {
236
+ data = null;
237
+ }
238
+ }
239
+ if (data === null || data === void 0 || typeof data !== "object") data = {};
240
+ const out = {
241
+ plan: FREE.plan,
242
+ status: FREE.status,
243
+ seats: "seats" in data ? Math.max(1, numOr(data.seats, FREE.seats)) : FREE.seats,
244
+ previewPersistence: "previewPersistence" in data ? Boolean(data.previewPersistence) : FREE.previewPersistence,
245
+ previewSharing: "previewSharing" in data ? Boolean(data.previewSharing) : FREE.previewSharing,
246
+ maxProjects: "maxProjects" in data ? numOr(data.maxProjects, FREE.maxProjects) : FREE.maxProjects,
247
+ maxActivePreviews: "maxActivePreviews" in data ? numOr(data.maxActivePreviews, FREE.maxActivePreviews) : FREE.maxActivePreviews,
248
+ captureEnabled: "captureEnabled" in data ? Boolean(data.captureEnabled) : FREE.captureEnabled
249
+ };
250
+ if (r.plan !== void 0 && r.plan !== null && String(r.plan).trim() !== "") {
251
+ out.plan = /** @type {Entitlements['plan']} */
252
+ String(r.plan);
253
+ }
254
+ if (r.status !== void 0 && r.status !== null && String(r.status).trim() !== "") {
255
+ out.status = /** @type {Entitlements['status']} */
256
+ String(r.status);
257
+ }
258
+ return Object.freeze(out);
259
+ }
260
+ async function getEntitlements(db, orgId) {
261
+ const { rows } = await db.query(
262
+ "SELECT plan, status, data FROM entitlements WHERE org_id = $1 LIMIT 1",
263
+ [orgId]
264
+ );
265
+ if (!rows || rows.length === 0) return FREE;
266
+ return normalizeEntitlements(rows[0]);
267
+ }
268
+ function effectiveEntitlements(ent) {
269
+ if (ent && (ent.status === "past_due" || ent.status === "canceled")) {
270
+ return Object.freeze({
271
+ ...ent,
272
+ // Overlay FREE's gate fields; keep plan + status from ent.
273
+ seats: FREE.seats,
274
+ previewPersistence: FREE.previewPersistence,
275
+ previewSharing: FREE.previewSharing,
276
+ maxProjects: FREE.maxProjects,
277
+ maxActivePreviews: FREE.maxActivePreviews,
278
+ captureEnabled: FREE.captureEnabled,
279
+ plan: ent.plan,
280
+ status: ent.status
281
+ });
282
+ }
283
+ return ent;
284
+ }
285
+
286
+ // src/server.js
287
+ var createServer = ({
288
+ store,
289
+ config,
290
+ db = null,
291
+ getLatestTokens = () => ({}),
292
+ getResolvedTokens = () => null,
293
+ getArtifactIndex = () => null,
294
+ getArtifact = () => null,
295
+ onError = () => {
296
+ }
297
+ }) => {
298
+ const namespace = config.namespace;
299
+ const corsOrigin = config.corsOrigins && config.corsOrigins !== "*" ? config.corsOrigins : "*";
300
+ const hosted = Boolean(config.databaseUrl);
301
+ const upgradeUrl = process.env.SORB_UPGRADE_URL || "/billing";
302
+ const previewTtlMs = config.previewTtlMs || 864e5;
303
+ const app = new import_hono.Hono();
304
+ if (hosted) {
305
+ app.use("*", async (c, next) => {
306
+ const path = c.req.path;
307
+ if (path === "/health" || path === "/ready") return next();
308
+ if (c.req.method === "OPTIONS") return next();
309
+ let ctx;
310
+ try {
311
+ ctx = await resolveApiKey(db, c.req.header("Authorization"));
312
+ } catch (e) {
313
+ onError(e, { at: "auth" });
314
+ return c.json({ error: "Service unavailable", code: "db_unavailable" }, 503);
315
+ }
316
+ if (!ctx) {
317
+ return c.json({ error: "Unauthorized", code: "unauthorized" }, 401);
318
+ }
319
+ c.set("auth", ctx);
320
+ let ent;
321
+ try {
322
+ ent = effectiveEntitlements(await getEntitlements(db, ctx.orgId));
323
+ } catch (e) {
324
+ onError(e, { at: "entitlements" });
325
+ return c.json({ error: "Service unavailable", code: "db_unavailable" }, 503);
326
+ }
327
+ c.set("ent", ent);
328
+ return next();
329
+ });
330
+ app.use(
331
+ "*",
332
+ (0, import_cors.cors)({
333
+ origin: (origin, c) => {
334
+ if (!origin) return origin;
335
+ if (c.req.method === "OPTIONS") return origin;
336
+ const ctx = c.get("auth");
337
+ if (ctx && Array.isArray(ctx.allowedOrigins) && ctx.allowedOrigins.includes(origin)) {
338
+ return origin;
339
+ }
340
+ return void 0;
341
+ }
342
+ })
343
+ );
344
+ } else {
345
+ app.use("*", (0, import_cors.cors)({ origin: corsOrigin }));
346
+ }
347
+ const requireWrite = (c) => {
348
+ if (!hosted) return null;
349
+ const ctx = c.get("auth");
350
+ if (ctx.scope !== SCOPE.WRITE) {
351
+ return c.json({ error: "Publishable keys are read-only", code: "read_only" }, 403);
352
+ }
353
+ return null;
354
+ };
355
+ const activePreviewCount = async (projectId) => {
356
+ const res = await db.query(
357
+ "SELECT count(*)::int AS n FROM previews WHERE project_id = $1 AND (expires_at IS NULL OR expires_at > now())",
358
+ [projectId]
359
+ );
360
+ const row = res && res.rows && res.rows[0];
361
+ return row ? Number(row.n) || 0 : 0;
362
+ };
363
+ const requireSharing = (c) => {
364
+ const ent = c.get("ent");
365
+ if (!ent || ent.previewSharing !== true) {
366
+ return c.json({ error: "Preview sharing is not available on your plan", code: "sharing_locked", upgradeUrl }, 402);
367
+ }
368
+ return null;
369
+ };
370
+ app.post("/preview", async (c) => {
371
+ if (hosted) {
372
+ const denied = requireWrite(c);
373
+ if (denied) return denied;
374
+ const ctx = c.get("auth");
375
+ const ent = c.get("ent");
376
+ if (c.req.query("share") === "1") {
377
+ const locked = requireSharing(c);
378
+ if (locked) return locked;
379
+ }
380
+ if (ent.maxActivePreviews !== -1) {
381
+ let active;
382
+ try {
383
+ active = await activePreviewCount(ctx.projectId);
384
+ } catch (e) {
385
+ onError(e, { at: "preview.count" });
386
+ return c.json({ error: "Service unavailable", code: "db_unavailable" }, 503);
387
+ }
388
+ if (active >= ent.maxActivePreviews) {
389
+ return c.json(
390
+ {
391
+ error: `Active preview limit reached (${ent.maxActivePreviews}). Upgrade for more concurrent previews.`,
392
+ code: "preview_limit",
393
+ upgradeUrl
394
+ },
395
+ 402
396
+ );
397
+ }
398
+ }
399
+ const tokens2 = await c.req.json();
400
+ const id2 = (0, import_nanoid.nanoid)(8);
401
+ const ttlMs = ent.previewPersistence ? null : previewTtlMs;
402
+ await store.putPreview(id2, tokens2, ttlMs);
403
+ const expiresAt = ttlMs == null ? null : new Date(Date.now() + ttlMs);
404
+ try {
405
+ await db.query(
406
+ "INSERT INTO previews (id, project_id, expires_at) VALUES ($1, $2, $3)",
407
+ [id2, ctx.projectId, expiresAt]
408
+ );
409
+ } catch (e) {
410
+ onError(e, { at: "preview.bookkeeping.insert", id: id2 });
411
+ }
412
+ const url2 = `?preview=${id2}`;
413
+ return c.json({ id: id2, url: url2 });
414
+ }
415
+ const tokens = await c.req.json();
416
+ const id = (0, import_nanoid.nanoid)(8);
417
+ await store.putPreview(id, tokens);
418
+ const url = `?preview=${id}`;
419
+ return c.json({ id, url });
420
+ });
421
+ app.get("/preview/:id", async (c) => {
422
+ const id = c.req.param("id");
423
+ const entry = await store.getPreview(id);
424
+ if (!entry) {
425
+ return c.json({ error: "Preview not found or expired" }, 404);
426
+ }
427
+ if (hosted) {
428
+ const ctx = c.get("auth");
429
+ let owned = false;
430
+ try {
431
+ const res = await db.query(
432
+ "SELECT 1 FROM previews WHERE id = $1 AND project_id = $2 LIMIT 1",
433
+ [id, ctx.projectId]
434
+ );
435
+ owned = Boolean(res && res.rows && res.rows.length);
436
+ } catch (e) {
437
+ onError(e, { at: "preview.owner", method: "GET", id });
438
+ return c.json({ error: "Service unavailable", code: "db_unavailable" }, 503);
439
+ }
440
+ if (!owned) {
441
+ return c.json({ error: "Preview not found or expired" }, 404);
442
+ }
443
+ }
444
+ return c.json(entry.tokens);
445
+ });
446
+ app.put("/preview/:id", async (c) => {
447
+ const id = c.req.param("id");
448
+ if (hosted) {
449
+ const denied = requireWrite(c);
450
+ if (denied) return denied;
451
+ const ctx = c.get("auth");
452
+ let owned = false;
453
+ try {
454
+ const res = await db.query(
455
+ "SELECT 1 FROM previews WHERE id = $1 AND project_id = $2 LIMIT 1",
456
+ [id, ctx.projectId]
457
+ );
458
+ owned = Boolean(res && res.rows && res.rows.length);
459
+ } catch (e) {
460
+ onError(e, { at: "preview.owner", method: "PUT", id });
461
+ return c.json({ error: "Service unavailable", code: "db_unavailable" }, 503);
462
+ }
463
+ if (!owned) {
464
+ return c.json({ error: "Preview not found" }, 404);
465
+ }
466
+ }
467
+ const tokens = await c.req.json();
468
+ const updated = await store.updatePreview(id, tokens);
469
+ if (!updated) {
470
+ return c.json({ error: "Preview not found" }, 404);
471
+ }
472
+ return c.json({ id, updated: true });
473
+ });
474
+ app.delete("/preview/:id", async (c) => {
475
+ const id = c.req.param("id");
476
+ if (hosted) {
477
+ const denied = requireWrite(c);
478
+ if (denied) return denied;
479
+ const ctx = c.get("auth");
480
+ let owned = false;
481
+ try {
482
+ const res = await db.query(
483
+ "SELECT 1 FROM previews WHERE id = $1 AND project_id = $2 LIMIT 1",
484
+ [id, ctx.projectId]
485
+ );
486
+ owned = Boolean(res && res.rows && res.rows.length);
487
+ } catch (e) {
488
+ onError(e, { at: "preview.owner", method: "DELETE", id });
489
+ return c.json({ error: "Service unavailable", code: "db_unavailable" }, 503);
490
+ }
491
+ if (!owned) {
492
+ return c.json({ error: "Preview not found" }, 404);
493
+ }
494
+ await store.deletePreview(id);
495
+ try {
496
+ await db.query("DELETE FROM previews WHERE id = $1 AND project_id = $2", [id, ctx.projectId]);
497
+ } catch (e) {
498
+ onError(e, { at: "preview.bookkeeping.delete", id });
499
+ }
500
+ return c.json({ deleted: true });
501
+ }
502
+ await store.deletePreview(id);
503
+ return c.json({ deleted: true });
504
+ });
505
+ app.post("/verify", async (c) => {
506
+ if (hosted) {
507
+ const denied = requireWrite(c);
508
+ if (denied) return denied;
509
+ }
510
+ const { storyId, bbox, meta } = await c.req.json();
511
+ const id = (0, import_nanoid.nanoid)(8);
512
+ await store.putVerification(id, { storyId, bbox, meta });
513
+ return c.json({ id });
514
+ });
515
+ app.get("/verify/latest", async (c) => {
516
+ const entry = await store.getLatestVerification();
517
+ if (!entry) {
518
+ return c.json({ error: "No verification reported yet" }, 404);
519
+ }
520
+ return c.json(entry);
521
+ });
522
+ app.get("/verify/:id", async (c) => {
523
+ const entry = await store.getVerification(c.req.param("id"));
524
+ if (!entry) {
525
+ return c.json({ error: "Verification not found or expired" }, 404);
526
+ }
527
+ return c.json(entry);
528
+ });
529
+ app.get("/tokens/latest", (c) => {
530
+ return c.json(getLatestTokens());
531
+ });
532
+ app.get("/tokens/resolved", (c) => {
533
+ const resolved = getResolvedTokens();
534
+ if (!resolved) {
535
+ return c.json(
536
+ { error: "No resolved token map. Run `sorb-seed resolve` (Style Dictionary build)." },
537
+ 404
538
+ );
539
+ }
540
+ return c.json(resolved);
541
+ });
542
+ app.get("/artifacts", (c) => {
543
+ const idx = getArtifactIndex();
544
+ if (!idx) {
545
+ return c.json(
546
+ { error: "No artifact index. Run `sorb-seed capture`." },
547
+ 404
548
+ );
549
+ }
550
+ return c.json(idx);
551
+ });
552
+ app.get("/artifact", (c) => {
553
+ const storyId = c.req.query("id");
554
+ if (!storyId) return c.json({ error: "Missing ?id=" }, 400);
555
+ const art = getArtifact(storyId);
556
+ if (!art) return c.json({ error: "Artifact not found for id: " + storyId }, 404);
557
+ return c.json(art);
558
+ });
559
+ app.get("/health", async (c) => {
560
+ return c.json({
561
+ ok: true,
562
+ namespace,
563
+ activePreviews: await store.countPreviews(),
564
+ verifications: await store.countVerifications()
565
+ });
566
+ });
567
+ app.get("/ready", async (c) => {
568
+ const checks = {};
569
+ try {
570
+ checks.store = await store.ping();
571
+ } catch (e) {
572
+ checks.store = false;
573
+ }
574
+ if (db) {
575
+ try {
576
+ checks.db = await db.ping();
577
+ } catch (e) {
578
+ checks.db = false;
579
+ }
580
+ }
581
+ const ok = Object.values(checks).every(Boolean);
582
+ return c.json({ ok, checks }, ok ? 200 : 503);
583
+ });
584
+ return app;
585
+ };
586
+
587
+ // src/store/memory.js
588
+ var createMemoryStore = (config) => {
589
+ const ttlMs = config.previewTtlMs;
590
+ const pruneIntervalMs = config.pruneIntervalMs;
591
+ const previews = /* @__PURE__ */ new Map();
592
+ const verifications = /* @__PURE__ */ new Map();
593
+ let latestVerifyId = null;
594
+ const isExpired = (entry, now) => now - entry.createdAt > ttlMs;
595
+ const pruneTimer = setInterval(() => {
596
+ const now = Date.now();
597
+ for (const [id, entry] of previews) {
598
+ if (isExpired(entry, now)) previews.delete(id);
599
+ }
600
+ for (const [id, entry] of verifications) {
601
+ if (isExpired(entry, now)) {
602
+ verifications.delete(id);
603
+ if (id === latestVerifyId) latestVerifyId = null;
604
+ }
605
+ }
606
+ }, pruneIntervalMs);
607
+ if (typeof pruneTimer.unref === "function") pruneTimer.unref();
608
+ const putPreview = async (id, tokens) => {
609
+ previews.set(id, { tokens, createdAt: Date.now() });
610
+ };
611
+ const getPreview = async (id) => {
612
+ const entry = previews.get(id);
613
+ if (!entry) return null;
614
+ if (isExpired(entry, Date.now())) {
615
+ previews.delete(id);
616
+ return null;
617
+ }
618
+ return entry;
619
+ };
620
+ const hasPreview = async (id) => {
621
+ return await getPreview(id) !== null;
622
+ };
623
+ const updatePreview = async (id, tokens) => {
624
+ if (!await hasPreview(id)) return false;
625
+ previews.set(id, { tokens, createdAt: Date.now() });
626
+ return true;
627
+ };
628
+ const deletePreview = async (id) => {
629
+ previews.delete(id);
630
+ };
631
+ const countPreviews = async () => {
632
+ const now = Date.now();
633
+ let n = 0;
634
+ for (const entry of previews.values()) {
635
+ if (!isExpired(entry, now)) n++;
636
+ }
637
+ return n;
638
+ };
639
+ const putVerification = async (id, { storyId, bbox, meta }) => {
640
+ verifications.set(id, { storyId, bbox, meta, createdAt: Date.now() });
641
+ latestVerifyId = id;
642
+ };
643
+ const getVerification = async (id) => {
644
+ const entry = verifications.get(id);
645
+ if (!entry) return null;
646
+ if (isExpired(entry, Date.now())) {
647
+ verifications.delete(id);
648
+ if (id === latestVerifyId) latestVerifyId = null;
649
+ return null;
650
+ }
651
+ return entry;
652
+ };
653
+ const getLatestVerification = async () => {
654
+ if (!latestVerifyId) return null;
655
+ return getVerification(latestVerifyId);
656
+ };
657
+ const countVerifications = async () => {
658
+ const now = Date.now();
659
+ let n = 0;
660
+ for (const entry of verifications.values()) {
661
+ if (!isExpired(entry, now)) n++;
662
+ }
663
+ return n;
664
+ };
665
+ const ping = async () => true;
666
+ const close = async () => {
667
+ clearInterval(pruneTimer);
668
+ };
669
+ return {
670
+ putPreview,
671
+ getPreview,
672
+ hasPreview,
673
+ updatePreview,
674
+ deletePreview,
675
+ countPreviews,
676
+ putVerification,
677
+ getVerification,
678
+ getLatestVerification,
679
+ countVerifications,
680
+ ping,
681
+ close
682
+ };
683
+ };
684
+
685
+ // src/store/index.js
686
+ var createStore = async (config) => {
687
+ if (config.redisUrl) {
688
+ const { createRedisStore: createRedisStore2 } = await Promise.resolve().then(() => (init_redis(), redis_exports));
689
+ return createRedisStore2(config);
690
+ }
691
+ return createMemoryStore(config);
692
+ };
693
+
694
+ // src/config.js
695
+ var DEFAULTS = Object.freeze({
696
+ PORT: 7777,
697
+ SORB_NAMESPACE: "sorb-local",
698
+ REDIS_URL: void 0,
699
+ DATABASE_URL: void 0,
700
+ CORS_ORIGINS: "*",
701
+ // open by default = free local mode
702
+ PREVIEW_TTL_MS: 864e5,
703
+ // 24h — preserves today's preview/verify lifetime
704
+ PRUNE_INTERVAL_MS: 36e5,
705
+ // 1h — preserves today's in-memory prune cadence
706
+ NODE_ENV: "development"
707
+ });
708
+ var intOr = (raw, fallback) => {
709
+ if (raw === void 0 || raw === null || String(raw).trim() === "") return fallback;
710
+ const n = Number.parseInt(String(raw), 10);
711
+ return Number.isFinite(n) && n > 0 ? n : fallback;
712
+ };
713
+ var strOrUndef = (raw) => {
714
+ if (raw === void 0 || raw === null) return void 0;
715
+ const s = String(raw).trim();
716
+ return s === "" ? void 0 : s;
717
+ };
718
+ var parseCorsOrigins = (raw) => {
719
+ const s = strOrUndef(raw);
720
+ if (s === void 0) return "*";
721
+ const parts = s.split(",").map((o) => o.trim()).filter((o) => o !== "");
722
+ if (parts.length === 0 || parts.includes("*")) return "*";
723
+ return Array.from(new Set(parts));
724
+ };
725
+ var loadConfig = (env = process.env) => {
726
+ const redisUrl = strOrUndef(env.REDIS_URL);
727
+ const databaseUrl = strOrUndef(env.DATABASE_URL);
728
+ const config = {
729
+ port: intOr(env.PORT, DEFAULTS.PORT),
730
+ namespace: strOrUndef(env.SORB_NAMESPACE) ?? DEFAULTS.SORB_NAMESPACE,
731
+ // Presence of these URLs is the switch that activates each hosted backend.
732
+ redisUrl,
733
+ databaseUrl,
734
+ corsOrigins: parseCorsOrigins(env.CORS_ORIGINS),
735
+ previewTtlMs: intOr(env.PREVIEW_TTL_MS, DEFAULTS.PREVIEW_TTL_MS),
736
+ pruneIntervalMs: intOr(env.PRUNE_INTERVAL_MS, DEFAULTS.PRUNE_INTERVAL_MS),
737
+ nodeEnv: strOrUndef(env.NODE_ENV) ?? DEFAULTS.NODE_ENV,
738
+ // Convenience flags so the store factory + /ready don't re-derive presence.
739
+ redisEnabled: redisUrl !== void 0,
740
+ databaseEnabled: databaseUrl !== void 0,
741
+ hosted: redisUrl !== void 0 || databaseUrl !== void 0
742
+ };
743
+ return Object.freeze(config);
744
+ };
745
+
746
+ // src/db/migrate.js
747
+ var import_promises = require("node:fs/promises");
748
+ var import_node_path = require("node:path");
749
+ var import_node_url = require("node:url");
750
+ var SELF_URL = __sorbModuleUrl;
751
+ var MIGRATIONS_DIR = (0, import_node_path.join)((0, import_node_path.dirname)((0, import_node_url.fileURLToPath)(SELF_URL)), "migrations");
752
+ var SCHEMA_MIGRATIONS_DDL = `
753
+ CREATE TABLE IF NOT EXISTS schema_migrations (
754
+ filename text PRIMARY KEY,
755
+ applied_at timestamptz NOT NULL DEFAULT now()
756
+ )
757
+ `;
758
+ async function runMigrations(db, opts) {
759
+ if (!db) {
760
+ throw new Error("runMigrations: no DbHandle (DATABASE_URL not configured?)");
761
+ }
762
+ const dir = opts && opts.dir || MIGRATIONS_DIR;
763
+ await db.query(SCHEMA_MIGRATIONS_DDL);
764
+ let entries;
765
+ try {
766
+ entries = await (0, import_promises.readdir)(dir);
767
+ } catch (e) {
768
+ if (e && e.code === "ENOENT") return [];
769
+ throw e;
770
+ }
771
+ const files = entries.filter((f) => f.toLowerCase().endsWith(".sql")).sort((a, b) => a < b ? -1 : a > b ? 1 : 0);
772
+ const appliedRes = await db.query("SELECT filename FROM schema_migrations");
773
+ const already = new Set((appliedRes.rows || []).map((r) => r.filename));
774
+ const applied = [];
775
+ for (const file of files) {
776
+ if (already.has(file)) continue;
777
+ const sql = await (0, import_promises.readFile)((0, import_node_path.join)(dir, file), "utf8");
778
+ await db.tx(async (client) => {
779
+ await client.query(sql);
780
+ await client.query("INSERT INTO schema_migrations (filename) VALUES ($1)", [file]);
781
+ });
782
+ applied.push(file);
783
+ console.log(`[db] applied migration ${file}`);
784
+ }
785
+ return applied;
786
+ }
787
+
788
+ // src/db/index.js
789
+ async function createDb(config) {
790
+ const databaseUrl = config && config.databaseUrl;
791
+ if (!databaseUrl) {
792
+ return null;
793
+ }
794
+ const pgModule = await import("pg");
795
+ const pg = pgModule.default || pgModule;
796
+ const { Pool } = pg;
797
+ const pool = new Pool({
798
+ connectionString: databaseUrl,
799
+ // Keep the pool conservative; the bridge is mostly Redis-bound. These can
800
+ // be tuned later via config if needed.
801
+ max: 10,
802
+ idleTimeoutMillis: 3e4,
803
+ connectionTimeoutMillis: 1e4
804
+ });
805
+ pool.on("error", (err) => {
806
+ console.error("[db] idle pool client error:", err && err.message ? err.message : err);
807
+ });
808
+ async function query(text, params) {
809
+ return pool.query(text, params);
810
+ }
811
+ async function getClient() {
812
+ return pool.connect();
813
+ }
814
+ async function tx(fn) {
815
+ const client = await pool.connect();
816
+ try {
817
+ await client.query("BEGIN");
818
+ const result = await fn(client);
819
+ await client.query("COMMIT");
820
+ return result;
821
+ } catch (e) {
822
+ try {
823
+ await client.query("ROLLBACK");
824
+ } catch (rollbackErr) {
825
+ console.error("[db] rollback failed:", rollbackErr && rollbackErr.message ? rollbackErr.message : rollbackErr);
826
+ }
827
+ throw e;
828
+ } finally {
829
+ client.release();
830
+ }
831
+ }
832
+ async function ping() {
833
+ try {
834
+ const res = await pool.query("SELECT 1 AS ok");
835
+ return Boolean(res && res.rows && res.rows.length === 1);
836
+ } catch (e) {
837
+ console.error("[db] ping failed:", e && e.message ? e.message : e);
838
+ return false;
839
+ }
840
+ }
841
+ let closed = false;
842
+ async function close() {
843
+ if (closed) return;
844
+ closed = true;
845
+ try {
846
+ await pool.end();
847
+ } catch (e) {
848
+ console.error("[db] close failed:", e && e.message ? e.message : e);
849
+ }
850
+ }
851
+ const handle = {
852
+ pool,
853
+ query,
854
+ getClient,
855
+ tx,
856
+ ping,
857
+ close,
858
+ /** Apply pending SQL migrations in order. @returns {Promise<string[]>} */
859
+ runMigrations: () => runMigrations(handle)
860
+ };
861
+ return handle;
862
+ }
863
+
864
+ // src/watch.js
865
+ var import_chokidar = __toESM(require("chokidar"));
866
+ var import_fs = require("fs");
867
+ var import_path = require("path");
868
+ var import_picocolors = __toESM(require("picocolors"));
869
+ var watchTokenFile = (tokenPath, onChange) => {
870
+ const abs = (0, import_path.resolve)(process.cwd(), tokenPath);
871
+ if (!(0, import_fs.existsSync)(abs)) {
872
+ console.error(import_picocolors.default.red(` \u2717 Token file not found: ${abs}`));
873
+ process.exit(1);
874
+ }
875
+ const read = () => {
876
+ try {
877
+ return JSON.parse((0, import_fs.readFileSync)(abs, "utf-8"));
878
+ } catch (err) {
879
+ console.error(import_picocolors.default.red(` \u2717 Failed to parse token file: ${abs}`));
880
+ return {};
881
+ }
882
+ };
883
+ const watcher = import_chokidar.default.watch(abs, {
884
+ ignoreInitial: true,
885
+ awaitWriteFinish: { stabilityThreshold: 100 }
886
+ });
887
+ watcher.on("change", () => {
888
+ console.log(import_picocolors.default.cyan(` \u2192 Token file changed`));
889
+ onChange(read());
890
+ });
891
+ watcher.on("error", (err) => {
892
+ console.error(import_picocolors.default.red(` \u2717 Watcher error:`), err);
893
+ });
894
+ return {
895
+ read,
896
+ stop: () => watcher.close()
897
+ };
898
+ };
899
+
900
+ // src/transform.js
901
+ var import_child_process = require("child_process");
902
+ var import_fs2 = require("fs");
903
+ var import_path2 = require("path");
904
+ var import_picocolors2 = __toESM(require("picocolors"));
905
+ var runStyleDictionary = (configPath) => {
906
+ const abs = (0, import_path2.resolve)(process.cwd(), configPath);
907
+ if (!(0, import_fs2.existsSync)(abs)) {
908
+ console.warn(
909
+ import_picocolors2.default.yellow(` \u26A0 style-dictionary config not found at ${configPath}, skipping`)
910
+ );
911
+ return false;
912
+ }
913
+ try {
914
+ console.log(import_picocolors2.default.dim(" \u2192 Running Style Dictionary..."));
915
+ (0, import_child_process.execSync)(`npx style-dictionary build --config ${abs}`, {
916
+ stdio: "inherit",
917
+ cwd: process.cwd()
918
+ });
919
+ console.log(import_picocolors2.default.green(" \u2713 Style Dictionary build complete"));
920
+ return true;
921
+ } catch (e) {
922
+ console.error(import_picocolors2.default.red(" \u2717 Style Dictionary build failed"));
923
+ return false;
924
+ }
925
+ };
926
+
927
+ // src/github.js
928
+ var import_picocolors3 = __toESM(require("picocolors"));
929
+ var openTokenPR = async (opts) => {
930
+ const base = `https://api.github.com/repos/${opts.owner}/${opts.repo}`;
931
+ const headers = {
932
+ Authorization: `Bearer ${opts.pat}`,
933
+ "Content-Type": "application/json",
934
+ Accept: "application/vnd.github+json"
935
+ };
936
+ const mainRef = await fetch(`${base}/git/ref/heads/main`, { headers }).then((r) => r.json());
937
+ const sha = mainRef.object.sha;
938
+ const branchName = `tokens/update-${Date.now()}`;
939
+ await fetch(`${base}/git/refs`, {
940
+ method: "POST",
941
+ headers,
942
+ body: JSON.stringify({
943
+ ref: `refs/heads/${branchName}`,
944
+ sha
945
+ })
946
+ });
947
+ const fileRes = await fetch(`${base}/contents/${opts.tokenPath}`, {
948
+ headers
949
+ });
950
+ const fileData = fileRes.ok ? await fileRes.json() : null;
951
+ await fetch(`${base}/contents/${opts.tokenPath}`, {
952
+ method: "PUT",
953
+ headers,
954
+ body: JSON.stringify({
955
+ message: opts.message,
956
+ content: Buffer.from(opts.content).toString("base64"),
957
+ branch: branchName,
958
+ ...fileData?.sha ? { sha: fileData.sha } : {}
959
+ })
960
+ });
961
+ const pr = await fetch(`${base}/pulls`, {
962
+ method: "POST",
963
+ headers,
964
+ body: JSON.stringify({
965
+ title: opts.message,
966
+ head: branchName,
967
+ base: "main",
968
+ body: [
969
+ "> \u{1F3A8} Created by **Sorb**",
970
+ "",
971
+ "This PR was generated from the Sorb Figma plugin.",
972
+ "Review the token diff and merge to apply changes to the app."
973
+ ].join("\n")
974
+ })
975
+ }).then((r) => r.json());
976
+ return pr.html_url;
977
+ };
978
+ // Annotate the CommonJS export names for ESM import in node:
979
+ 0 && (module.exports = {
980
+ createDb,
981
+ createMemoryStore,
982
+ createServer,
983
+ createStore,
984
+ loadConfig,
985
+ openTokenPR,
986
+ runStyleDictionary,
987
+ watchTokenFile
988
+ });
989
+ //# sourceMappingURL=index.js.map