@hachej/boring-core 0.1.45 → 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-FZICNVJW.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
  }
@@ -443,12 +457,20 @@ function captureAppOpened(telemetry, requestId) {
443
457
  }
444
458
  function registerTelemetryHooks(app, telemetry) {
445
459
  app.addHook("onResponse", async (request, reply) => {
446
- 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;
447
469
  safeCapture(telemetry, {
448
470
  name: "server.request.failed",
449
471
  properties: {
450
472
  requestId: request.id,
451
- status: reply.statusCode,
473
+ status,
452
474
  errorCode: ERROR_CODES.INTERNAL_ERROR
453
475
  }
454
476
  });
@@ -481,7 +503,7 @@ async function registerFrontendFallback(app, appRoot, telemetry) {
481
503
  return serveFrontendShell(request, reply, indexPath, telemetry);
482
504
  });
483
505
  }
484
- async function createCoreRuntime(config) {
506
+ async function createCoreRuntime(config, customTelemetry) {
485
507
  if (config.stores !== "postgres") {
486
508
  throw new Error("createCoreWorkspaceAgentServer currently supports only CORE_STORES=postgres");
487
509
  }
@@ -493,18 +515,19 @@ async function createCoreRuntime(config) {
493
515
  config.encryption.workspaceSettingsKey
494
516
  );
495
517
  const app = await createCoreApp(config);
496
- const auth = createAuth(config, db, {
497
- workspaceStore,
498
- logger: app.log
499
- });
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 });
500
522
  app.decorate("db", db);
501
523
  app.decorate("auth", auth);
502
524
  app.decorate("userStore", userStore);
503
525
  app.decorate("workspaceStore", workspaceStore);
526
+ app.decorate("telemetry", telemetry);
504
527
  app.addHook("onClose", async () => {
505
528
  await sql.end();
506
529
  });
507
- return { app, sql, db, userStore, workspaceStore };
530
+ return { app, sql, db, userStore, workspaceStore, telemetry };
508
531
  }
509
532
  async function registerCoreRoutes({
510
533
  app,
@@ -534,14 +557,11 @@ async function createCoreWorkspaceAgentServer(options = {}) {
534
557
  }
535
558
  assertCoreStaticPluginEntries(options.plugins);
536
559
  const config = options.config ?? await loadConfig(resolveCoreLoadConfigOptions(options));
537
- const { app, sql, db, userStore, workspaceStore } = await createCoreRuntime(config);
560
+ const { app, sql, db, userStore, workspaceStore, telemetry } = await createCoreRuntime(config, options.telemetry);
538
561
  const appRoot = options.appRoot;
539
562
  const serveFrontend = options.serveFrontend ?? (process.env.NODE_ENV !== "development" && Boolean(appRoot));
540
563
  const pluginWorkspaceRoot = process.cwd();
541
564
  const workspaceRoot = options.workspaceRoot ?? process.env.BORING_AGENT_WORKSPACE_ROOT ?? process.cwd();
542
- const telemetrySource = options.telemetry ? "custom" : process.env.BORING_TELEMETRY_ENABLED === "true" ? "db-env" : "noop-env";
543
- const telemetry = options.telemetry ?? createDatabaseTelemetryFromEnv(db, { appId: config.appId }, process.env);
544
- app.log.debug({ telemetry: { source: telemetrySource } }, "resolved telemetry sink");
545
565
  registerTelemetryHooks(app, telemetry);
546
566
  await registerCoreRoutes({ app, sql, db, userStore, workspaceStore });
547
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,
@@ -1742,6 +1746,7 @@ function buildMailTransport(config) {
1742
1746
  }
1743
1747
  function createAuth(config, db, opts) {
1744
1748
  const transport = buildMailTransport(config);
1749
+ const telemetry = opts?.telemetry ?? noopTelemetry;
1745
1750
  const emailVerificationConfig = transport ? {
1746
1751
  sendOnSignUp: true,
1747
1752
  sendVerificationEmail: async (data) => {
@@ -1802,13 +1807,35 @@ function createAuth(config, db, opts) {
1802
1807
  baseURL: config.auth.url,
1803
1808
  basePath: "/auth",
1804
1809
  trustedOrigins: config.cors.origins,
1805
- databaseHooks: postSignupHook ? {
1810
+ databaseHooks: {
1806
1811
  user: {
1807
1812
  create: {
1808
- 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
+ }
1809
1836
  }
1810
1837
  }
1811
- } : void 0,
1838
+ },
1812
1839
  advanced: {
1813
1840
  database: {
1814
1841
  generateId: "uuid"
@@ -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';
@@ -600,9 +601,10 @@ declare class CreditsService {
600
601
  private readonly store;
601
602
  readonly config: CreditsConfig;
602
603
  private readonly log?;
604
+ private readonly telemetry;
603
605
  /** Users whose signup grant was ensured this process; avoids an INSERT per balance poll. */
604
606
  private readonly signupGrantedUsers;
605
- constructor(store: CreditsMeteringStore, config?: CreditsConfig, log?: CreditsLogger | undefined);
607
+ constructor(store: CreditsMeteringStore, config?: CreditsConfig, log?: CreditsLogger | undefined, telemetry?: TelemetrySink);
606
608
  /** Idempotently grant the free starter credits (call from the post-signup hook
607
609
  * and lazily on first balance/reserve). The grant NEVER expires: an expiring
608
610
  * grant would drop from grantedMicros on expiry while spent usage stayed, turning
@@ -999,6 +1001,11 @@ interface CreditsRoutesOptions {
999
1001
  lemonSqueezy?: LemonSqueezyRouteOptions;
1000
1002
  stripe?: StripeRouteOptions;
1001
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;
1002
1009
  }
1003
1010
  /**
1004
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-FZICNVJW.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.45",
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.45",
83
- "@hachej/boring-workspace": "0.1.45",
84
- "@hachej/boring-agent": "0.1.45"
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",