@hachej/boring-core 0.1.44 → 0.1.46

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.
@@ -59,6 +59,9 @@ interface ReserveInput {
59
59
  }
60
60
  interface ReserveResult {
61
61
  reservationId: string;
62
+ /** false when an existing active reservation for this run was returned (idempotent
63
+ * retry), true when a new hold was placed. Lets callers avoid double-counting. */
64
+ created: boolean;
62
65
  }
63
66
  interface RecordUsageInput {
64
67
  /** Stable idempotency key; a second insert with the same id is a no-op. */
@@ -120,6 +123,7 @@ declare class PostgresMeteringStore {
120
123
  variantId?: string;
121
124
  }): Promise<{
122
125
  granted: boolean;
126
+ refundAppliedMicros?: number;
123
127
  }>;
124
128
  /**
125
129
  * Revoke a refunded/disputed purchase. Under the same per-order advisory lock
@@ -9,12 +9,25 @@ interface ChatFirstPublicShellOptions {
9
9
  emptyState?: {
10
10
  eyebrow?: string;
11
11
  title?: string;
12
- description?: string;
12
+ description?: ReactNode;
13
13
  footer?: ReactNode;
14
14
  };
15
15
  suggestions?: ChatSuggestion[];
16
16
  /**
17
- * Hand-drawn "Your agent" / "Your remote computer" annotations point at the
17
+ * Predefined models for the composer's model picker. When provided, the
18
+ * composer settings row (model + thinking pickers) is shown in the no-auth
19
+ * hero, mirroring a regular chat session. The no-auth shell can't fetch the
20
+ * model list from the server (server resources are disabled), so it must be
21
+ * passed in here. `available` defaults to `true`.
22
+ */
23
+ models?: Array<{
24
+ provider: string;
25
+ id: string;
26
+ label: string;
27
+ available?: boolean;
28
+ }>;
29
+ /**
30
+ * Hand-drawn "Ask your AI here" / "Review its work here" annotations point at the
18
31
  * composer and the workspace surface to teach the empty public shell. Apps
19
32
  * that open a center panel on load (e.g. a landing page) should disable them,
20
33
  * otherwise the fixed-position arrows overlay the open panel. Defaults to on.
@@ -54,7 +54,7 @@ function ChatFirstAuthenticatedShell({
54
54
  ...workspaceProps,
55
55
  workspaceId,
56
56
  appTitle,
57
- topBarLeft: null,
57
+ topBarLeft: workspaceProps.topBarLeft ?? null,
58
58
  sessions: [],
59
59
  activeSessionId: null,
60
60
  onSwitchSession: () => void 0,
@@ -264,6 +264,8 @@ function ChatFirstPublicShell({
264
264
  const promptedDraft = new URLSearchParams(location.search).get("prompt")?.trim() ?? "";
265
265
  const pendingReturnTo = promptedDraft ? "/" : returnTo;
266
266
  const workspaceId = intendedWorkspaceId || "public";
267
+ const publicModels = (publicShell?.models ?? []).map((m) => ({ ...m, available: m.available ?? true }));
268
+ const showComposerSettings = publicModels.length > 0;
267
269
  const openAuth = (draft = readComposerDraftFromDom()) => {
268
270
  writePendingChatEntry({ draft, returnTo: pendingReturnTo, ...intendedWorkspaceId ? { intendedWorkspaceId } : {} });
269
271
  setModalOpen(true);
@@ -356,7 +358,7 @@ function ChatFirstPublicShell({
356
358
  return /* @__PURE__ */ jsxs3("div", { className: "public-chat-first-shell relative h-screen min-h-0 bg-background", children: [
357
359
  publicShell?.showTeachingArrows !== false && /* @__PURE__ */ jsxs3(Fragment2, { children: [
358
360
  /* @__PURE__ */ jsxs3("div", { className: "public-arrow public-arrow-computer", "aria-hidden": "true", children: [
359
- /* @__PURE__ */ jsx4("span", { className: "public-arrow-label", children: "Your remote computer" }),
361
+ /* @__PURE__ */ jsx4("span", { className: "public-arrow-label", children: "Review its work here" }),
360
362
  /* @__PURE__ */ jsxs3("svg", { className: "public-arrow-svg", viewBox: "0 0 190 140", fill: "none", children: [
361
363
  /* @__PURE__ */ jsx4(
362
364
  "path",
@@ -381,7 +383,7 @@ function ChatFirstPublicShell({
381
383
  /* @__PURE__ */ jsx4("path", { className: "paw-stroke", d: "M76 10 C 70 16 62 19 53 20" }),
382
384
  /* @__PURE__ */ jsx4("path", { className: "paw-stroke", d: "M76 10 C 82 14 87 21 90 29" })
383
385
  ] }),
384
- /* @__PURE__ */ jsx4("span", { className: "public-arrow-label", children: "Your agent" })
386
+ /* @__PURE__ */ jsx4("span", { className: "public-arrow-label", children: "Ask your AI here" })
385
387
  ] })
386
388
  ] }),
387
389
  /* @__PURE__ */ jsx4("aside", { className: "pointer-events-none fixed bottom-6 left-6 z-20 w-[300px]", children: /* @__PURE__ */ jsx4("div", { className: "pointer-events-auto", children: /* @__PURE__ */ jsx4(AuthCard, { returnTo }) }) }),
@@ -393,16 +395,38 @@ function ChatFirstPublicShell({
393
395
  showComposerBlocker: false,
394
396
  workspaceProps: {
395
397
  ...workspaceProps,
398
+ // No-auth landing should open with the workspace surface closed —
399
+ // a fresh load/hard refresh shows just the hero, not a re-opened
400
+ // panel. The /landing-page command still opens it on demand.
401
+ defaultSurfaceOpen: false,
396
402
  topBarRight: /* @__PURE__ */ jsx4("button", { type: "button", className: "rounded-md border border-border px-3 py-1.5 text-sm font-medium hover:bg-muted", onClick: () => openAuth(), children: "Sign in" }),
403
+ // No-auth shell has no real session yet — show just the brand, hide the
404
+ // "· New session" placeholder that the default TopBar would render.
405
+ topBarLeft: /* @__PURE__ */ jsxs3(Fragment2, { children: [
406
+ /* @__PURE__ */ jsx4(
407
+ "span",
408
+ {
409
+ "aria-hidden": "true",
410
+ className: "grid size-[22px] shrink-0 place-items-center rounded-sm bg-foreground text-[11px] font-semibold leading-none tracking-tight text-background",
411
+ children: (appTitle?.[0] ?? "B").toUpperCase()
412
+ }
413
+ ),
414
+ /* @__PURE__ */ jsx4("span", { className: "truncate text-[13px] font-medium leading-none tracking-tight text-foreground", children: appTitle })
415
+ ] }),
397
416
  className: workspaceProps.className,
398
417
  surfaceButtonBottomOffset: 456,
399
418
  chatParams: {
400
419
  ...workspaceProps.chatParams,
401
420
  emptyPlacement: "hero",
402
421
  composerPlaceholder: publicShell?.composerPlaceholder ?? "Type /landing-page or /reach-out",
403
- hideComposerSettings: true,
422
+ hideComposerSettings: !showComposerSettings,
404
423
  suppressPreSubmitCancelledWarning: true,
405
- thinkingControl: false,
424
+ thinkingControl: showComposerSettings,
425
+ ...showComposerSettings ? {
426
+ availableModels: publicModels,
427
+ hideDefaultModelOption: true,
428
+ defaultModel: { provider: publicModels[0].provider, id: publicModels[0].id }
429
+ } : {},
406
430
  initialDraft: promptedDraft || void 0,
407
431
  emptyState: {
408
432
  ...defaultPublicEmptyState,
@@ -4,7 +4,7 @@ import { WorkspaceServerPlugin } from '@hachej/boring-workspace/server';
4
4
  import { FastifyInstance } from 'fastify';
5
5
  import { C as CoreConfig } from '../../types-CWtJ4kgd.js';
6
6
  import { TelemetrySink } from '../../shared/index.js';
7
- import { B as BetterAuthInstance, L as LoadConfigOptions } from '../../authHook-CzBsMwwM.js';
7
+ import { B as BetterAuthInstance, L as LoadConfigOptions } from '../../authHook-DtzhSmqS.js';
8
8
  import { D as Database, U as UserStore, W as WorkspaceStore } from '../../connection-C5SiqoNc.js';
9
9
  import { IncomingMessage, ServerResponse } from 'node:http';
10
10
  import 'better-auth';
@@ -16,6 +16,9 @@ type CoreWorkspaceAgentServer = FastifyInstance & {
16
16
  db: Database;
17
17
  userStore: UserStore;
18
18
  workspaceStore: WorkspaceStore;
19
+ /** Best-effort telemetry sink (DB-backed when BORING_TELEMETRY_ENABLED=true, else noop).
20
+ * Consumed by request hooks and the credit service to emit product events. */
21
+ telemetry: TelemetrySink;
19
22
  };
20
23
  type CoreWorkspaceAgentServerPlugin = WorkspaceServerPlugin & {
21
24
  provisioning?: RuntimeProvisioningContribution;
@@ -9,13 +9,13 @@ import {
9
9
  registerRoutes,
10
10
  registerSettingsRoutes,
11
11
  registerWorkspaceRoutes
12
- } from "../../chunk-UM5SHYIS.js";
12
+ } from "../../chunk-6GAQRQKO.js";
13
13
  import {
14
14
  PostgresUserStore,
15
15
  PostgresWorkspaceStore,
16
16
  createDatabase,
17
17
  telemetryEvents
18
- } from "../../chunk-I56OTSPB.js";
18
+ } from "../../chunk-FZC3VL5D.js";
19
19
  import {
20
20
  noopTelemetry,
21
21
  safeCapture
@@ -82,7 +82,13 @@ var ALLOWED_PROPERTY_KEYS = /* @__PURE__ */ new Set([
82
82
  "skillCount",
83
83
  "templateDirCount",
84
84
  "packageName",
85
- "packageVersion"
85
+ "packageVersion",
86
+ // Credits / billing (provider/pack/currency/reason are slugs; creditMicros is a non-negative int).
87
+ "provider",
88
+ "packId",
89
+ "currency",
90
+ "reason",
91
+ "creditMicros"
86
92
  ]);
87
93
  function createDatabaseTelemetryFromEnv(db, options, env = process.env) {
88
94
  if (env.BORING_TELEMETRY_ENABLED !== "true") return noopTelemetry;
@@ -135,6 +141,9 @@ function sanitizeTelemetryProperty(key, value) {
135
141
  if (key.endsWith("Count")) {
136
142
  return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : void 0;
137
143
  }
144
+ if (key.endsWith("Micros")) {
145
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : void 0;
146
+ }
138
147
  if (key === "status" && typeof value === "number") {
139
148
  return Number.isInteger(value) && value >= 100 && value <= 599 ? value : void 0;
140
149
  }
@@ -171,6 +180,11 @@ function sanitizeTelemetryString(key, value) {
171
180
  return SAFE_PACKAGE_NAME_PATTERN.test(value) ? value : void 0;
172
181
  case "packageVersion":
173
182
  return SAFE_PACKAGE_VERSION_PATTERN.test(value) ? value : void 0;
183
+ case "provider":
184
+ case "packId":
185
+ case "currency":
186
+ case "reason":
187
+ return SAFE_SLUG_PATTERN.test(value) ? value : void 0;
174
188
  default:
175
189
  return void 0;
176
190
  }
@@ -208,6 +222,13 @@ var FRONTEND_AUTH_PAGES_SPA_ONLY = /* @__PURE__ */ new Set([
208
222
  "/auth/callback/github",
209
223
  "/auth/callback/google"
210
224
  ]);
225
+ function resolveCoreLoadConfigOptions(options = {}, nodeEnv = process.env.NODE_ENV) {
226
+ return {
227
+ allowMissingSecrets: nodeEnv !== "production",
228
+ ...options.appRoot && !options.loadConfigOptions?.tomlPath ? { tomlPath: path.resolve(options.appRoot, "boring.app.toml") } : {},
229
+ ...options.loadConfigOptions
230
+ };
231
+ }
211
232
  function dedupeStrings(values) {
212
233
  return Array.from(new Set(values));
213
234
  }
@@ -436,12 +457,20 @@ function captureAppOpened(telemetry, requestId) {
436
457
  }
437
458
  function registerTelemetryHooks(app, telemetry) {
438
459
  app.addHook("onResponse", async (request, reply) => {
439
- if (reply.statusCode < 500) return;
460
+ const status = reply.statusCode;
461
+ if (status === 429) {
462
+ safeCapture(telemetry, {
463
+ name: "server.request.rate_limited",
464
+ properties: { requestId: request.id }
465
+ });
466
+ return;
467
+ }
468
+ if (status < 500) return;
440
469
  safeCapture(telemetry, {
441
470
  name: "server.request.failed",
442
471
  properties: {
443
472
  requestId: request.id,
444
- status: reply.statusCode,
473
+ status,
445
474
  errorCode: ERROR_CODES.INTERNAL_ERROR
446
475
  }
447
476
  });
@@ -474,7 +503,7 @@ async function registerFrontendFallback(app, appRoot, telemetry) {
474
503
  return serveFrontendShell(request, reply, indexPath, telemetry);
475
504
  });
476
505
  }
477
- async function createCoreRuntime(config) {
506
+ async function createCoreRuntime(config, customTelemetry) {
478
507
  if (config.stores !== "postgres") {
479
508
  throw new Error("createCoreWorkspaceAgentServer currently supports only CORE_STORES=postgres");
480
509
  }
@@ -486,18 +515,19 @@ async function createCoreRuntime(config) {
486
515
  config.encryption.workspaceSettingsKey
487
516
  );
488
517
  const app = await createCoreApp(config);
489
- const auth = createAuth(config, db, {
490
- workspaceStore,
491
- logger: app.log
492
- });
518
+ const telemetry = customTelemetry ?? createDatabaseTelemetryFromEnv(db, { appId: config.appId }, process.env);
519
+ const telemetrySource = customTelemetry ? "custom" : process.env.BORING_TELEMETRY_ENABLED === "true" ? "db-env" : "noop-env";
520
+ app.log.debug({ telemetry: { source: telemetrySource } }, "resolved telemetry sink");
521
+ const auth = createAuth(config, db, { workspaceStore, logger: app.log, telemetry });
493
522
  app.decorate("db", db);
494
523
  app.decorate("auth", auth);
495
524
  app.decorate("userStore", userStore);
496
525
  app.decorate("workspaceStore", workspaceStore);
526
+ app.decorate("telemetry", telemetry);
497
527
  app.addHook("onClose", async () => {
498
528
  await sql.end();
499
529
  });
500
- return { app, sql, db, userStore, workspaceStore };
530
+ return { app, sql, db, userStore, workspaceStore, telemetry };
501
531
  }
502
532
  async function registerCoreRoutes({
503
533
  app,
@@ -526,18 +556,12 @@ async function createCoreWorkspaceAgentServer(options = {}) {
526
556
  );
527
557
  }
528
558
  assertCoreStaticPluginEntries(options.plugins);
529
- const config = options.config ?? await loadConfig({
530
- allowMissingSecrets: process.env.NODE_ENV !== "production",
531
- ...options.loadConfigOptions
532
- });
533
- const { app, sql, db, userStore, workspaceStore } = await createCoreRuntime(config);
559
+ const config = options.config ?? await loadConfig(resolveCoreLoadConfigOptions(options));
560
+ const { app, sql, db, userStore, workspaceStore, telemetry } = await createCoreRuntime(config, options.telemetry);
534
561
  const appRoot = options.appRoot;
535
562
  const serveFrontend = options.serveFrontend ?? (process.env.NODE_ENV !== "development" && Boolean(appRoot));
536
563
  const pluginWorkspaceRoot = process.cwd();
537
564
  const workspaceRoot = options.workspaceRoot ?? process.env.BORING_AGENT_WORKSPACE_ROOT ?? process.cwd();
538
- const telemetrySource = options.telemetry ? "custom" : process.env.BORING_TELEMETRY_ENABLED === "true" ? "db-env" : "noop-env";
539
- const telemetry = options.telemetry ?? createDatabaseTelemetryFromEnv(db, { appId: config.appId }, process.env);
540
- app.log.debug({ telemetry: { source: telemetrySource } }, "resolved telemetry sink");
541
565
  registerTelemetryHooks(app, telemetry);
542
566
  await registerCoreRoutes({ app, sql, db, userStore, workspaceStore });
543
567
  if (serveFrontend && appRoot) {
@@ -2,6 +2,7 @@ import { C as CoreConfig, R as RuntimeConfig } from './types-CWtJ4kgd.js';
2
2
  import { FastifyPluginAsync } from 'fastify';
3
3
  import { Auth } from 'better-auth';
4
4
  import { W as WorkspaceStore, D as Database } from './connection-C5SiqoNc.js';
5
+ import { TelemetrySink } from './shared/index.js';
5
6
 
6
7
  interface LoadConfigOptions {
7
8
  tomlPath?: string;
@@ -21,6 +22,8 @@ interface CreateAuthOptions {
21
22
  logger?: {
22
23
  warn: (obj: Record<string, unknown>, msg: string) => void;
23
24
  };
25
+ /** Telemetry sink for auth.signed_up / auth.session_started (defaults to noop). */
26
+ telemetry?: TelemetrySink;
24
27
  }
25
28
  declare function createAuth(config: CoreConfig, db: Database, opts?: CreateAuthOptions): Auth<any>;
26
29
  type BetterAuthInstance = Auth<any>;
@@ -12,7 +12,11 @@ import {
12
12
  workspaceRuntimes,
13
13
  workspaceSettings,
14
14
  workspaces
15
- } from "./chunk-I56OTSPB.js";
15
+ } from "./chunk-FZC3VL5D.js";
16
+ import {
17
+ noopTelemetry,
18
+ safeCapture
19
+ } from "./chunk-AQBXNPMD.js";
16
20
  import {
17
21
  ConfigValidationError,
18
22
  ERROR_CODES,
@@ -101,6 +105,30 @@ var SIXTEEN_MB = 16 * 1024 * 1024;
101
105
  var INSECURE_PLACEHOLDER_SECRET = "0000000000000000000000000000000000000000000000000000000000000000";
102
106
  var INSECURE_DATABASE_URL = "postgres://placeholder:placeholder@localhost:5432/placeholder";
103
107
  var INSECURE_ENCRYPTION_KEY = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
108
+ function formatDisplayName(name) {
109
+ const trimmed = name.trim().replace(/[<>]/g, "");
110
+ if (/^[A-Za-z0-9 ._-]+$/.test(trimmed)) return trimmed;
111
+ return `"${trimmed.replace(/["\\]/g, "\\$&")}"`;
112
+ }
113
+ function isDefaultBoringDisplayName(name) {
114
+ return name.toLowerCase().replace(/[\s._-]+/g, "") === "boringui";
115
+ }
116
+ function normalizeMailFrom(appName, rawFrom) {
117
+ const from = rawFrom.trim();
118
+ const addressWithDisplay = from.match(/^(.*?)\s*<([^>]+)>$/);
119
+ if (addressWithDisplay) {
120
+ const displayName = addressWithDisplay[1].trim().replace(/^"(.*)"$/, "$1");
121
+ const address = addressWithDisplay[2].trim();
122
+ if (!displayName || isDefaultBoringDisplayName(displayName)) {
123
+ return `${formatDisplayName(appName)} <${address}>`;
124
+ }
125
+ return from;
126
+ }
127
+ if (/^[^\s@<>]+@[^\s@<>]+$/.test(from)) {
128
+ return `${formatDisplayName(appName)} <${from}>`;
129
+ }
130
+ return from;
131
+ }
104
132
  function parseRateLimitOverrides(raw) {
105
133
  if (!raw) return void 0;
106
134
  try {
@@ -224,7 +252,18 @@ async function loadConfig(options) {
224
252
  ...toml.features?.invite_ttl_days != null && { inviteTtlDays: toml.features.invite_ttl_days }
225
253
  }
226
254
  };
227
- return validateConfig(raw);
255
+ const config = validateConfig(raw);
256
+ if (!config.auth.mail) return config;
257
+ return {
258
+ ...config,
259
+ auth: {
260
+ ...config.auth,
261
+ mail: {
262
+ ...config.auth.mail,
263
+ from: normalizeMailFrom(config.appName, config.auth.mail.from)
264
+ }
265
+ }
266
+ };
228
267
  }
229
268
  function validateConfig(raw) {
230
269
  const result = coreConfigSchema.safeParse(raw);
@@ -1423,6 +1462,7 @@ import { Button as Button4, Section as Section5, Text as Text5 } from "@react-em
1423
1462
  import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
1424
1463
  function WorkspaceInvite({
1425
1464
  acceptUrl,
1465
+ appName,
1426
1466
  inviterName,
1427
1467
  workspaceName,
1428
1468
  role,
@@ -1432,7 +1472,7 @@ function WorkspaceInvite({
1432
1472
  Layout,
1433
1473
  {
1434
1474
  preview: `${inviterName} invited you to ${workspaceName}`,
1435
- appName: workspaceName,
1475
+ appName,
1436
1476
  children: /* @__PURE__ */ jsxs5(Section5, { style: content4, children: [
1437
1477
  /* @__PURE__ */ jsx5(Text5, { style: heading4, children: "You've been invited" }),
1438
1478
  /* @__PURE__ */ jsxs5(Text5, { style: paragraph4, children: [
@@ -1568,6 +1608,7 @@ async function renderMagicLink(data) {
1568
1608
  async function renderWorkspaceInvite(data) {
1569
1609
  const element = WorkspaceInvite({
1570
1610
  acceptUrl: data.acceptUrl,
1611
+ appName: data.appName,
1571
1612
  inviterName: data.inviterName,
1572
1613
  workspaceName: data.workspaceName,
1573
1614
  role: data.role,
@@ -1705,6 +1746,7 @@ function buildMailTransport(config) {
1705
1746
  }
1706
1747
  function createAuth(config, db, opts) {
1707
1748
  const transport = buildMailTransport(config);
1749
+ const telemetry = opts?.telemetry ?? noopTelemetry;
1708
1750
  const emailVerificationConfig = transport ? {
1709
1751
  sendOnSignUp: true,
1710
1752
  sendVerificationEmail: async (data) => {
@@ -1765,13 +1807,35 @@ function createAuth(config, db, opts) {
1765
1807
  baseURL: config.auth.url,
1766
1808
  basePath: "/auth",
1767
1809
  trustedOrigins: config.cors.origins,
1768
- databaseHooks: postSignupHook ? {
1810
+ databaseHooks: {
1769
1811
  user: {
1770
1812
  create: {
1771
- after: postSignupHook
1813
+ // auth.signed_up is emitted here (not in postSignupHook) so it fires for ALL
1814
+ // signups, independent of whether workspace post-signup setup is wired.
1815
+ // distinctId = user id; no properties (no PII, nothing the DB sink would drop).
1816
+ after: async (user, ctx) => {
1817
+ safeCapture(telemetry, {
1818
+ name: "auth.signed_up",
1819
+ distinctId: typeof user?.id === "string" ? user.id : void 0
1820
+ });
1821
+ if (postSignupHook) await postSignupHook(user, ctx);
1822
+ }
1823
+ }
1824
+ },
1825
+ // Fires for every new session — sign-in AND the session minted on sign-up — so the
1826
+ // name reflects that (a true returning-sign-in count = session_started minus the
1827
+ // first session per user, derivable in SQL). distinctId = user id; no properties.
1828
+ session: {
1829
+ create: {
1830
+ after: async (session) => {
1831
+ safeCapture(telemetry, {
1832
+ name: "auth.session_started",
1833
+ distinctId: typeof session?.userId === "string" ? session.userId : void 0
1834
+ });
1835
+ }
1772
1836
  }
1773
1837
  }
1774
- } : void 0,
1838
+ },
1775
1839
  advanced: {
1776
1840
  database: {
1777
1841
  generateId: "uuid"
@@ -2426,6 +2490,7 @@ var inviteRoutesPlugin = async (app, opts) => {
2426
2490
  const email = await renderWorkspaceInvite({
2427
2491
  to: parsed.data.email,
2428
2492
  acceptUrl,
2493
+ appName: app.config.appName,
2429
2494
  inviterName: request.user.name ?? request.user.email,
2430
2495
  workspaceName: workspace?.name ?? "Workspace",
2431
2496
  role: parsed.data.role,
@@ -1890,7 +1890,7 @@ var PostgresMeteringStore = class {
1890
1890
  metadata: { kind: "purchase_refund", orderId: input.orderId, refundedToMicros: revoke, appliedAtGrant: true }
1891
1891
  });
1892
1892
  }
1893
- return { granted: true };
1893
+ return { granted: true, refundAppliedMicros: revoke > 0 ? revoke : void 0 };
1894
1894
  }
1895
1895
  await tx.insert(creditPurchases).values({
1896
1896
  orderId: input.orderId,
@@ -2090,7 +2090,7 @@ var PostgresMeteringStore = class {
2090
2090
  eq3(usageReservations.status, "active")
2091
2091
  )).limit(1);
2092
2092
  const existingId = existing[0]?.id;
2093
- if (existingId) return { reservationId: existingId };
2093
+ if (existingId) return { reservationId: existingId, created: false };
2094
2094
  const balance = await this.computeBalance(tx, input.userId, now);
2095
2095
  if (balance.availableMicros < minAvailable) {
2096
2096
  throw new InsufficientCreditError(balance.availableMicros, minAvailable);
@@ -2107,7 +2107,7 @@ var PostgresMeteringStore = class {
2107
2107
  }).returning({ id: usageReservations.id });
2108
2108
  const reservationId = rows[0]?.id;
2109
2109
  if (!reservationId) throw new Error("reservation insert returned no id");
2110
- return { reservationId };
2110
+ return { reservationId, created: true };
2111
2111
  });
2112
2112
  }
2113
2113
  /** Idempotent ledger insert; returns whether a new row was written. Serialized
@@ -1,6 +1,6 @@
1
1
  import { U as UserStore, W as WorkspaceStore } from '../../connection-C5SiqoNc.js';
2
2
  export { D as Database, c as createDatabase } from '../../connection-C5SiqoNc.js';
3
- export { F as FinishReservationInput, G as GrantOnceInput, I as InsufficientCreditError, M as MeteringBalance, P as PostgresMeteringStore, R as RecordUsageInput, a as RecordUsageResult, b as ReservationFinalStatus, c as ReserveInput, d as ReserveResult, e as RunMigrationsOptions, r as runMigrations } from '../../PostgresMeteringStore-CzNv6xil.js';
3
+ export { F as FinishReservationInput, G as GrantOnceInput, I as InsufficientCreditError, M as MeteringBalance, P as PostgresMeteringStore, R as RecordUsageInput, a as RecordUsageResult, b as ReservationFinalStatus, c as ReserveInput, d as ReserveResult, e as RunMigrationsOptions, r as runMigrations } from '../../PostgresMeteringStore-DhOVVtau.js';
4
4
  import { U as User, W as Workspace, E as ERROR_CODES, M as MemberRole, a as WorkspaceMember, b as WorkspaceInvite, c as WorkspaceRuntime, d as WorkspaceRuntimeResourceSelector, e as WorkspaceRuntimeResource, f as WorkspaceRuntimeResourceInput } from '../../types-CWtJ4kgd.js';
5
5
  import { PostgresJsDatabase } from 'drizzle-orm/postgres-js';
6
6
  import 'postgres';
@@ -7,7 +7,7 @@ import {
7
7
  PostgresWorkspaceStore,
8
8
  createDatabase,
9
9
  runMigrations
10
- } from "../../chunk-I56OTSPB.js";
10
+ } from "../../chunk-FZC3VL5D.js";
11
11
  import "../../chunk-LIBHVT7V.js";
12
12
  import "../../chunk-MLKGABMK.js";
13
13
  export {
@@ -1,5 +1,5 @@
1
- import { L as LoadConfigOptions } from '../authHook-CzBsMwwM.js';
2
- export { A as AuthHookOptions, B as BetterAuthInstance, C as CreateAuthOptions, a as authHook, b as buildRuntimeConfigPayload, c as createAuth, l as loadConfig, v as validateConfig, d as validatePasswordStrength } from '../authHook-CzBsMwwM.js';
1
+ import { L as LoadConfigOptions } from '../authHook-DtzhSmqS.js';
2
+ export { A as AuthHookOptions, B as BetterAuthInstance, C as CreateAuthOptions, a as authHook, b as buildRuntimeConfigPayload, c as createAuth, l as loadConfig, v as validateConfig, d as validatePasswordStrength } from '../authHook-DtzhSmqS.js';
3
3
  import { z } from 'zod';
4
4
  import { C as CoreConfig, M as MemberRole, d as WorkspaceRuntimeResourceSelector, e as WorkspaceRuntimeResource, f as WorkspaceRuntimeResourceInput } from '../types-CWtJ4kgd.js';
5
5
  import * as fastify from 'fastify';
@@ -9,8 +9,9 @@ import { IncomingMessage } from 'node:http';
9
9
  import { C as CreateCoreAppOptions, D as Database, U as UserStore, W as WorkspaceStore, a as WorkspaceProvisioner } from '../connection-C5SiqoNc.js';
10
10
  export { A as AuthProvider, b as CapabilitiesContributor, P as ProvisionContext, d as ProvisionResult, c as createDatabase } from '../connection-C5SiqoNc.js';
11
11
  import postgres from 'postgres';
12
- import { P as PostgresMeteringStore, C as CreditLedgerEntry } from '../PostgresMeteringStore-CzNv6xil.js';
13
- export { F as FinishReservationInput, G as GrantOnceInput, I as InsufficientCreditError, M as MeteringBalance, R as RecordUsageInput, a as RecordUsageResult, b as ReservationFinalStatus, c as ReserveInput, d as ReserveResult, r as runMigrations } from '../PostgresMeteringStore-CzNv6xil.js';
12
+ import { P as PostgresMeteringStore, C as CreditLedgerEntry } from '../PostgresMeteringStore-DhOVVtau.js';
13
+ export { F as FinishReservationInput, G as GrantOnceInput, I as InsufficientCreditError, M as MeteringBalance, R as RecordUsageInput, a as RecordUsageResult, b as ReservationFinalStatus, c as ReserveInput, d as ReserveResult, r as runMigrations } from '../PostgresMeteringStore-DhOVVtau.js';
14
+ import { TelemetrySink } from '../shared/index.js';
14
15
  import { AgentMeteringSink } from '@hachej/boring-agent/server';
15
16
  import 'better-auth';
16
17
  import 'drizzle-orm/postgres-js';
@@ -326,6 +327,7 @@ declare function renderMagicLink(data: MagicLinkData): Promise<RenderedEmail>;
326
327
  interface WorkspaceInviteData {
327
328
  to: string;
328
329
  acceptUrl: string;
330
+ appName: string;
329
331
  inviterName: string;
330
332
  workspaceName: string;
331
333
  role: string;
@@ -599,9 +601,10 @@ declare class CreditsService {
599
601
  private readonly store;
600
602
  readonly config: CreditsConfig;
601
603
  private readonly log?;
604
+ private readonly telemetry;
602
605
  /** Users whose signup grant was ensured this process; avoids an INSERT per balance poll. */
603
606
  private readonly signupGrantedUsers;
604
- constructor(store: CreditsMeteringStore, config?: CreditsConfig, log?: CreditsLogger | undefined);
607
+ constructor(store: CreditsMeteringStore, config?: CreditsConfig, log?: CreditsLogger | undefined, telemetry?: TelemetrySink);
605
608
  /** Idempotently grant the free starter credits (call from the post-signup hook
606
609
  * and lazily on first balance/reserve). The grant NEVER expires: an expiring
607
610
  * grant would drop from grantedMicros on expiry while spent usage stayed, turning
@@ -998,6 +1001,11 @@ interface CreditsRoutesOptions {
998
1001
  lemonSqueezy?: LemonSqueezyRouteOptions;
999
1002
  stripe?: StripeRouteOptions;
1000
1003
  log?: (message: string, fields?: Record<string, unknown>) => void;
1004
+ /** Best-effort telemetry sink (default noop). Currently emits checkout.started /
1005
+ * purchase.webhook_rejected for the STRIPE routes only (the live provider); Lemon
1006
+ * Squeezy parity is a follow-up if it ships. purchase.completed/refunded + run.*
1007
+ * events come from CreditsService regardless of provider. */
1008
+ telemetry?: TelemetrySink;
1001
1009
  }
1002
1010
  /**
1003
1011
  * Register the credit balance endpoint and (optionally) the Lemon Squeezy
@@ -24,13 +24,17 @@ import {
24
24
  requireWorkspaceMember,
25
25
  validateConfig,
26
26
  validatePasswordStrength
27
- } from "../chunk-UM5SHYIS.js";
27
+ } from "../chunk-6GAQRQKO.js";
28
28
  import {
29
29
  InsufficientCreditError,
30
30
  PostgresMeteringStore,
31
31
  createDatabase,
32
32
  runMigrations
33
- } from "../chunk-I56OTSPB.js";
33
+ } from "../chunk-FZC3VL5D.js";
34
+ import {
35
+ noopTelemetry,
36
+ safeCapture
37
+ } from "../chunk-AQBXNPMD.js";
34
38
  import {
35
39
  ERROR_CODES
36
40
  } from "../chunk-LIBHVT7V.js";
@@ -238,10 +242,11 @@ function disabledBalance(userId) {
238
242
  };
239
243
  }
240
244
  var CreditsService = class {
241
- constructor(store, config = DEFAULT_CREDITS_CONFIG, log) {
245
+ constructor(store, config = DEFAULT_CREDITS_CONFIG, log, telemetry = noopTelemetry) {
242
246
  this.store = store;
243
247
  this.config = config;
244
248
  this.log = log;
249
+ this.telemetry = telemetry;
245
250
  if (!config.enabled) return;
246
251
  validatePricingConfig(config.pricing);
247
252
  const posInt = (n) => Number.isSafeInteger(n) && n > 0;
@@ -260,6 +265,7 @@ var CreditsService = class {
260
265
  store;
261
266
  config;
262
267
  log;
268
+ telemetry;
263
269
  /** Users whose signup grant was ensured this process; avoids an INSERT per balance poll. */
264
270
  signupGrantedUsers = /* @__PURE__ */ new Set();
265
271
  /** Idempotently grant the free starter credits (call from the post-signup hook
@@ -282,7 +288,21 @@ var CreditsService = class {
282
288
  * optional provider identity is persisted for audit/refund reconciliation. */
283
289
  async grantPurchase(userId, orderId, amountMicros, identity) {
284
290
  if (!this.config.enabled) return { created: false };
285
- const { granted } = await this.store.grantPurchaseOnce({ userId, orderId, amountMicros, ...identity });
291
+ const { granted, refundAppliedMicros } = await this.store.grantPurchaseOnce({ userId, orderId, amountMicros, ...identity });
292
+ if (granted) {
293
+ safeCapture(this.telemetry, {
294
+ name: "purchase.completed",
295
+ distinctId: userId,
296
+ properties: {
297
+ creditMicros: amountMicros,
298
+ currency: identity?.currency,
299
+ packId: identity?.variantId
300
+ }
301
+ });
302
+ }
303
+ if (refundAppliedMicros && refundAppliedMicros > 0) {
304
+ safeCapture(this.telemetry, { name: "purchase.refunded" });
305
+ }
286
306
  return { created: granted };
287
307
  }
288
308
  /** Revoke a refunded/disputed purchase. `refundFraction` is the cumulative
@@ -293,13 +313,15 @@ var CreditsService = class {
293
313
  * Idempotent per cumulative level. */
294
314
  async revokePurchase(orderId, opts = {}) {
295
315
  if (!this.config.enabled) return { revoked: false };
296
- return this.store.revokePurchase(orderId, {
316
+ const result = await this.store.revokePurchase(orderId, {
297
317
  refundFraction: opts.refundFraction,
298
318
  allowTombstone: opts.allowTombstone,
299
319
  expectedStoreId: opts.expectedStoreId,
300
320
  expectedTestMode: opts.expectedTestMode,
301
321
  expectedCurrency: opts.expectedCurrency
302
322
  });
323
+ if (result.revoked) safeCapture(this.telemetry, { name: "purchase.refunded" });
324
+ return result;
303
325
  }
304
326
  async getBalance(userId) {
305
327
  if (!this.config.enabled) return disabledBalance(userId);
@@ -332,7 +354,7 @@ var CreditsService = class {
332
354
  if (!this.config.enabled) return void 0;
333
355
  await this.grantSignupCredits(input.userId);
334
356
  try {
335
- const { reservationId } = await this.store.reserve({
357
+ const { reservationId, created } = await this.store.reserve({
336
358
  userId: input.userId,
337
359
  workspaceId: input.workspaceId,
338
360
  sessionId: input.sessionId,
@@ -344,9 +366,11 @@ var CreditsService = class {
344
366
  // is admitted only when available ≥ hold + floor (matches the config doc).
345
367
  minAvailableMicros: this.config.runReservationMicros + this.config.minBalanceMicros
346
368
  });
369
+ if (created) safeCapture(this.telemetry, { name: "run.started", distinctId: input.userId });
347
370
  return reservationId;
348
371
  } catch (error) {
349
372
  if (error instanceof InsufficientCreditError) {
373
+ safeCapture(this.telemetry, { name: "credits.exhausted", distinctId: input.userId });
350
374
  throw new CreditExhaustedError(await this.getBalance(input.userId));
351
375
  }
352
376
  throw error;
@@ -402,7 +426,8 @@ var CreditsService = class {
402
426
  }
403
427
  async settleRun(userId, runId, reservationId) {
404
428
  if (!this.config.enabled) return;
405
- await this.store.finishReservation(reservationId ? { reservationId } : { runId, userId }, "settled");
429
+ const { updated } = await this.store.finishReservation(reservationId ? { reservationId } : { runId, userId }, "settled");
430
+ if (updated) safeCapture(this.telemetry, { name: "run.completed", distinctId: userId });
406
431
  }
407
432
  async releaseRun(userId, runId, reservationId) {
408
433
  if (!this.config.enabled) return;
@@ -435,10 +460,11 @@ var CreditsService = class {
435
460
  metadata: { kind: metaKind, reservationId: input.reservationId ?? null, alreadyBilledMicros: alreadyBilled, currency: "credits" }
436
461
  });
437
462
  }
438
- await this.store.finishReservation(
463
+ const { updated } = await this.store.finishReservation(
439
464
  input.reservationId ? { reservationId: input.reservationId } : { runId: input.runId, userId: input.userId },
440
465
  "settled"
441
466
  );
467
+ if (updated) safeCapture(this.telemetry, { name: "run.completed", distinctId: input.userId });
442
468
  this.log?.("credits: fallback hold charge \u2014 topped up to the hold and settled (reconcile against missing/non-billable usage)", {
443
469
  kind: metaKind,
444
470
  runId: input.runId,
@@ -1163,6 +1189,7 @@ function defaultGetUserId(request) {
1163
1189
  }
1164
1190
  function registerCreditsRoutes(app, options) {
1165
1191
  const getUserId = options.getUserId ?? defaultGetUserId;
1192
+ const telemetry = options.telemetry ?? noopTelemetry;
1166
1193
  const balancePath = options.balancePath ?? "/api/credits/balance";
1167
1194
  if (options.lemonSqueezy && options.stripe) {
1168
1195
  throw new Error("credits: configure at most one purchase provider (lemonSqueezy OR stripe), not both");
@@ -1197,7 +1224,7 @@ function registerCreditsRoutes(app, options) {
1197
1224
  if (!options.service.config.enabled) {
1198
1225
  throw new Error("credits: cannot register Stripe checkout/webhook with a disabled credits service (paid orders would be acknowledged without crediting)");
1199
1226
  }
1200
- registerStripeRoutes(app, options.service, getUserId, stripeOpts, options.log);
1227
+ registerStripeRoutes(app, options.service, getUserId, stripeOpts, options.log, telemetry);
1201
1228
  return;
1202
1229
  }
1203
1230
  const ls = options.lemonSqueezy;
@@ -1392,7 +1419,7 @@ function registerCreditsRoutes(app, options) {
1392
1419
  });
1393
1420
  });
1394
1421
  }
1395
- function registerStripeRoutes(app, service, getUserId, stripe, log) {
1422
+ function registerStripeRoutes(app, service, getUserId, stripe, log, telemetry = noopTelemetry) {
1396
1423
  const creditMicrosPerUnit = service.config.pricing.creditMicrosPerUnit;
1397
1424
  const requireCurrency = (stripe.requireCurrency ?? "EUR").toUpperCase();
1398
1425
  if (NON_TWO_DECIMAL_CURRENCIES.has(requireCurrency)) {
@@ -1487,6 +1514,7 @@ function registerStripeRoutes(app, service, getUserId, stripe, log) {
1487
1514
  email: typeof email === "string" ? email : void 0,
1488
1515
  redirectUrl: checkout.redirectUrl
1489
1516
  });
1517
+ safeCapture(telemetry, { name: "checkout.started", distinctId: userId, properties: { provider: "stripe", packId } });
1490
1518
  return reply.send({ url });
1491
1519
  } catch (error) {
1492
1520
  log?.("credits: stripe checkout creation failed", { error: String(error) });
@@ -1539,7 +1567,17 @@ function registerStripeRoutes(app, service, getUserId, stripe, log) {
1539
1567
  }),
1540
1568
  log
1541
1569
  }
1542
- );
1570
+ ).catch((error) => {
1571
+ safeCapture(telemetry, { name: "purchase.webhook_rejected", properties: { provider: "stripe", reason: "handler_error" } });
1572
+ throw error;
1573
+ });
1574
+ if (result.status >= 400) {
1575
+ const reason = result.body?.reason;
1576
+ safeCapture(telemetry, {
1577
+ name: "purchase.webhook_rejected",
1578
+ properties: { provider: "stripe", reason: typeof reason === "string" ? reason : void 0 }
1579
+ });
1580
+ }
1543
1581
  return reply.code(result.status).send(result.body);
1544
1582
  });
1545
1583
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-core",
3
- "version": "0.1.44",
3
+ "version": "0.1.46",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Foundation package for boring-ui-v2 apps: DB, auth, config, HTTP app factory, and frontend app shell.",
@@ -79,9 +79,9 @@
79
79
  "react-router-dom": "^7.14.2",
80
80
  "smol-toml": "^1.6.1",
81
81
  "zod": "^3.25.76",
82
- "@hachej/boring-ui-kit": "0.1.44",
83
- "@hachej/boring-workspace": "0.1.44",
84
- "@hachej/boring-agent": "0.1.44"
82
+ "@hachej/boring-agent": "0.1.46",
83
+ "@hachej/boring-ui-kit": "0.1.46",
84
+ "@hachej/boring-workspace": "0.1.46"
85
85
  },
86
86
  "devDependencies": {
87
87
  "@testing-library/jest-dom": "^6.9.1",