@threadbase-sh/streamer 1.48.0 → 1.49.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/cli.cjs CHANGED
@@ -142369,7 +142369,7 @@ function createLogsRoutes() {
142369
142369
 
142370
142370
  // src/api/routes/misc.routes.ts
142371
142371
  var import_node_child_process2 = require("child_process");
142372
- var import_node_crypto = require("crypto");
142372
+ var import_node_crypto2 = require("crypto");
142373
142373
  var import_os7 = require("os");
142374
142374
 
142375
142375
  // src/db/repositories/push.repository.ts
@@ -142628,6 +142628,170 @@ var PushRepository = class {
142628
142628
 
142629
142629
  // src/api/routes/misc.routes.ts
142630
142630
  init_logger();
142631
+
142632
+ // src/services/push/apnsClient.ts
142633
+ var import_node_crypto = require("crypto");
142634
+ var import_node_http2 = require("http2");
142635
+ init_logger();
142636
+ var log3 = getLogger("apns");
142637
+ var APNS_HOST_SANDBOX = "api.sandbox.push.apple.com";
142638
+ var APNS_MAX_PAYLOAD_BYTES = 4096;
142639
+ var JWT_TTL_SECONDS = 3e3;
142640
+ var DEAD_TOKEN_REASONS = /* @__PURE__ */ new Set([
142641
+ "BadDeviceToken",
142642
+ "DeviceTokenNotForTopic",
142643
+ "Unregistered",
142644
+ "ExpiredToken"
142645
+ ]);
142646
+ function base64url3(input) {
142647
+ return Buffer.from(input).toString("base64url");
142648
+ }
142649
+ function readApnsCredentialsFromEnv(env = process.env) {
142650
+ const key = env.APNS_KEY;
142651
+ if (!key || key.trim().length === 0) return null;
142652
+ const keyId = env.APNS_KEY_ID?.trim();
142653
+ const teamId = env.APNS_TEAM_ID?.trim();
142654
+ const bundleId = env.APNS_BUNDLE_ID?.trim();
142655
+ if (!keyId || !teamId || !bundleId) return null;
142656
+ const host = env.APNS_HOST ?? APNS_HOST_SANDBOX;
142657
+ return { key, keyId, teamId, bundleId, host };
142658
+ }
142659
+ function describeMissingApnsCredentials(env = process.env) {
142660
+ if (!env.APNS_KEY || env.APNS_KEY.trim().length === 0) {
142661
+ return "APNS_KEY is not set, so Live Activity push is disabled. Set it to the p8 key contents (not a path) to enable it.";
142662
+ }
142663
+ const missing = [
142664
+ ["APNS_KEY_ID", env.APNS_KEY_ID],
142665
+ ["APNS_TEAM_ID", env.APNS_TEAM_ID],
142666
+ ["APNS_BUNDLE_ID", env.APNS_BUNDLE_ID]
142667
+ ].filter(([, value]) => !value || value.trim().length === 0).map(([name]) => name);
142668
+ if (missing.length === 0) return null;
142669
+ return `APNS_KEY is set but ${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} not, so Live Activity push is disabled. Under launchd, APNS_KEY_ID is derived from the AuthKey_<keyId>.p8 filename; the team and bundle ids must be set explicitly.`;
142670
+ }
142671
+ var ApnsClient = class {
142672
+ constructor(creds) {
142673
+ this.creds = creds;
142674
+ }
142675
+ creds;
142676
+ session = null;
142677
+ cachedJwt = null;
142678
+ /**
142679
+ * The `apns-topic` for Live Activity pushes.
142680
+ *
142681
+ * The `.push-type.liveactivity` suffix is mandatory and is why the signing key
142682
+ * must be Team Scoped (All Topics) — a key scoped to the bundle id alone
142683
+ * cannot sign this topic.
142684
+ */
142685
+ get topic() {
142686
+ return `${this.creds.bundleId}.push-type.liveactivity`;
142687
+ }
142688
+ /**
142689
+ * Mint or reuse the provider JWT.
142690
+ *
142691
+ * ES256 over the p8 key. Cached until shortly before expiry: Apple rejects a
142692
+ * token older than an hour, but minting one per request is wasteful and can
142693
+ * trip APNs' provider-token-update throttle.
142694
+ */
142695
+ getJwt(now = Date.now()) {
142696
+ const nowSeconds = Math.floor(now / 1e3);
142697
+ if (this.cachedJwt && this.cachedJwt.expiresAt > nowSeconds + 60) {
142698
+ return this.cachedJwt.token;
142699
+ }
142700
+ const header = base64url3(JSON.stringify({ alg: "ES256", kid: this.creds.keyId, typ: "JWT" }));
142701
+ const payload = base64url3(JSON.stringify({ iss: this.creds.teamId, iat: nowSeconds }));
142702
+ const signingInput = `${header}.${payload}`;
142703
+ const signature = (0, import_node_crypto.createSign)("SHA256").update(signingInput).sign({ key: this.creds.key, dsaEncoding: "ieee-p1363" });
142704
+ const token = `${signingInput}.${base64url3(signature)}`;
142705
+ this.cachedJwt = { token, expiresAt: nowSeconds + JWT_TTL_SECONDS };
142706
+ return token;
142707
+ }
142708
+ /**
142709
+ * Reuse one HTTP/2 session across sends.
142710
+ *
142711
+ * APNs expects a long-lived connection; a fresh TLS handshake per push is slow
142712
+ * and Apple treats connection churn as abuse.
142713
+ */
142714
+ getSession() {
142715
+ if (this.session && !this.session.closed && !this.session.destroyed) {
142716
+ return this.session;
142717
+ }
142718
+ const session = (0, import_node_http2.connect)(`https://${this.creds.host}`);
142719
+ session.on("error", (err) => {
142720
+ log3.warn("apns.session_error", { event: "apns.session_error", err: String(err) });
142721
+ });
142722
+ this.session = session;
142723
+ return session;
142724
+ }
142725
+ /**
142726
+ * Send one push.
142727
+ *
142728
+ * Resolves with a result rather than rejecting on an APNs rejection: a
142729
+ * rejected push is an expected outcome the caller must act on (retire the
142730
+ * token), not an exception. Only a genuinely unexpected local failure throws,
142731
+ * and the caller logs it.
142732
+ */
142733
+ async send(args) {
142734
+ const body = Buffer.from(JSON.stringify(args.payload), "utf-8");
142735
+ if (body.byteLength > APNS_MAX_PAYLOAD_BYTES) {
142736
+ throw new Error(
142737
+ `APNs payload is ${body.byteLength} bytes, over the ${APNS_MAX_PAYLOAD_BYTES} byte limit`
142738
+ );
142739
+ }
142740
+ const session = this.getSession();
142741
+ const headers = {
142742
+ [import_node_http2.constants.HTTP2_HEADER_METHOD]: "POST",
142743
+ [import_node_http2.constants.HTTP2_HEADER_PATH]: `/3/device/${args.deviceToken}`,
142744
+ [import_node_http2.constants.HTTP2_HEADER_AUTHORIZATION]: `bearer ${this.getJwt()}`,
142745
+ "apns-push-type": "liveactivity",
142746
+ "apns-topic": this.topic,
142747
+ "apns-priority": String(args.priority ?? 10),
142748
+ ...args.expirationSeconds != null && {
142749
+ "apns-expiration": String(args.expirationSeconds)
142750
+ },
142751
+ [import_node_http2.constants.HTTP2_HEADER_CONTENT_TYPE]: "application/json",
142752
+ [import_node_http2.constants.HTTP2_HEADER_CONTENT_LENGTH]: String(body.byteLength)
142753
+ };
142754
+ return new Promise((resolve4, reject) => {
142755
+ const req = session.request(headers);
142756
+ req.setTimeout(args.timeoutMs ?? 1e4, () => {
142757
+ req.close(import_node_http2.constants.NGHTTP2_CANCEL);
142758
+ resolve4({ ok: false, status: 0, reason: "Timeout", tokenDead: false });
142759
+ });
142760
+ let status = 0;
142761
+ req.on("response", (resHeaders) => {
142762
+ status = Number(resHeaders[import_node_http2.constants.HTTP2_HEADER_STATUS] ?? 0);
142763
+ });
142764
+ const chunks = [];
142765
+ req.on("data", (chunk) => chunks.push(chunk));
142766
+ req.on("error", reject);
142767
+ req.on("end", () => {
142768
+ const raw2 = Buffer.concat(chunks).toString("utf-8");
142769
+ let reason;
142770
+ if (raw2.length > 0) {
142771
+ try {
142772
+ reason = JSON.parse(raw2).reason;
142773
+ } catch {
142774
+ reason = raw2.slice(0, 200);
142775
+ }
142776
+ }
142777
+ resolve4({
142778
+ ok: status === 200,
142779
+ status,
142780
+ reason,
142781
+ tokenDead: reason != null && DEAD_TOKEN_REASONS.has(reason)
142782
+ });
142783
+ });
142784
+ req.end(body);
142785
+ });
142786
+ }
142787
+ /** Close the shared connection. Called on server shutdown. */
142788
+ close() {
142789
+ this.session?.close();
142790
+ this.session = null;
142791
+ }
142792
+ };
142793
+
142794
+ // src/api/routes/misc.routes.ts
142631
142795
  function numberOrNull(value) {
142632
142796
  return typeof value === "number" && Number.isFinite(value) ? value : null;
142633
142797
  }
@@ -142657,11 +142821,22 @@ function readRawBody3(req) {
142657
142821
  function verifyWebhookSignature(body, header, secret) {
142658
142822
  if (!header) return false;
142659
142823
  const provided = header.startsWith("sha256=") ? header.slice(7) : header;
142660
- const expected = (0, import_node_crypto.createHmac)("sha256", secret).update(body).digest("hex");
142824
+ const expected = (0, import_node_crypto2.createHmac)("sha256", secret).update(body).digest("hex");
142661
142825
  const a = Buffer.from(provided, "utf-8");
142662
142826
  const b2 = Buffer.from(expected, "utf-8");
142663
142827
  if (a.length !== b2.length) return false;
142664
- return (0, import_node_crypto.timingSafeEqual)(a, b2);
142828
+ return (0, import_node_crypto2.timingSafeEqual)(a, b2);
142829
+ }
142830
+ function describePushCapability(liveActivityEnabled, env = process.env) {
142831
+ if (liveActivityEnabled) return { liveActivity: true, notifications: false };
142832
+ return {
142833
+ liveActivity: false,
142834
+ notifications: false,
142835
+ // describeMissingApnsCredentials only explains a *credential* gap and
142836
+ // returns null once the credentials are complete — reachable here, because
142837
+ // an unavailable token store disables the feature with the key still set.
142838
+ liveActivityReason: describeMissingApnsCredentials(env) ?? "APNs credentials are set but the push token store is unavailable, so Live Activity push is disabled."
142839
+ };
142665
142840
  }
142666
142841
  var clientLog = getLogger("client");
142667
142842
  var createMiscRoutes = (deps) => {
@@ -142684,7 +142859,12 @@ var createMiscRoutes = (deps) => {
142684
142859
  featureFlags: true,
142685
142860
  // Same contract: this server serves GET /api/projects/summary, which the
142686
142861
  // Hub's grouped views need before they can draw a tree.
142687
- projectSummary: true
142862
+ projectSummary: true,
142863
+ // Delivery capability, not endpoint support: whether this server can
142864
+ // actually send a push, so mobile can hide an affordance instead of
142865
+ // registering tokens nothing will ever send to. Absent on older servers,
142866
+ // which a client should read as "unknown", not "unavailable".
142867
+ push: describePushCapability(deps.liveActivityPushEnabled())
142688
142868
  });
142689
142869
  });
142690
142870
  app.get("/api/profiles", (c) => c.json([]));
@@ -142745,9 +142925,10 @@ var createMiscRoutes = (deps) => {
142745
142925
  return c.json({ ok: true });
142746
142926
  });
142747
142927
  app.get("/api/push/health", (c) => {
142928
+ const push = describePushCapability(deps.liveActivityPushEnabled());
142748
142929
  const repo = deps.pushRepo();
142749
- if (!repo) return c.json({ tokens: [], available: false });
142750
- return c.json({ tokens: repo.listHealth(), available: true });
142930
+ if (!repo) return c.json({ tokens: [], available: false, push });
142931
+ return c.json({ tokens: repo.listHealth(), available: true, push });
142751
142932
  });
142752
142933
  app.post("/api/__update", async (c) => {
142753
142934
  const cfg = loadUpdateConfig();
@@ -142815,7 +142996,7 @@ var createPairRoutes = (deps) => {
142815
142996
  };
142816
142997
 
142817
142998
  // src/api/routes/progress.routes.ts
142818
- var import_node_crypto2 = __toESM(require("crypto"), 1);
142999
+ var import_node_crypto3 = __toESM(require("crypto"), 1);
142819
143000
  function readRawBody4(req) {
142820
143001
  return new Promise((resolve4, reject) => {
142821
143002
  const chunks = [];
@@ -142826,10 +143007,10 @@ function readRawBody4(req) {
142826
143007
  }
142827
143008
  function verifySignature(rawBody, signature, secret) {
142828
143009
  if (!signature || signature.length === 0) return false;
142829
- const expected = import_node_crypto2.default.createHmac("sha256", secret).update(rawBody).digest("hex");
143010
+ const expected = import_node_crypto3.default.createHmac("sha256", secret).update(rawBody).digest("hex");
142830
143011
  if (expected.length !== signature.length) return false;
142831
143012
  try {
142832
- return import_node_crypto2.default.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
143013
+ return import_node_crypto3.default.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
142833
143014
  } catch {
142834
143015
  return false;
142835
143016
  }
@@ -143382,7 +143563,7 @@ var import_promises13 = require("timers/promises");
143382
143563
 
143383
143564
  // src/db/query-timing.ts
143384
143565
  init_logger();
143385
- var log3 = getLogger("db");
143566
+ var log4 = getLogger("db");
143386
143567
  var DEFAULT_SLOW_QUERY_MS = 35;
143387
143568
  var LABEL = /* @__PURE__ */ Symbol("tbQueryLabel");
143388
143569
  function deriveLabel(sql) {
@@ -143398,15 +143579,15 @@ function resolveSlowMs() {
143398
143579
  }
143399
143580
  function record2(label, ms2, rows, slowMs) {
143400
143581
  if (slowMs > 0 && ms2 >= slowMs) {
143401
- log3.warn(
143582
+ log4.warn(
143402
143583
  `[db] slow query ${label} ${ms2.toFixed(1)}ms rows=${rows}`,
143403
143584
  { event: "db.slow_query", stmt: label, ms: Math.round(ms2 * 100) / 100, rows },
143404
143585
  "pino"
143405
143586
  );
143406
143587
  return;
143407
143588
  }
143408
- if (log3.pino.isLevelEnabled("debug")) {
143409
- log3.debug(
143589
+ if (log4.pino.isLevelEnabled("debug")) {
143590
+ log4.debug(
143410
143591
  `[db] ${label} ${ms2.toFixed(2)}ms rows=${rows}`,
143411
143592
  { event: "db.query", stmt: label, ms: Math.round(ms2 * 100) / 100, rows },
143412
143593
  "pino"
@@ -146810,168 +146991,6 @@ function deriveProjectChatTitle(input) {
146810
146991
  return `Untitled \xB7 ${input.id.slice(0, 8)}`;
146811
146992
  }
146812
146993
 
146813
- // src/services/push/apnsClient.ts
146814
- var import_node_crypto3 = require("crypto");
146815
- var import_node_http2 = require("http2");
146816
- init_logger();
146817
- var log4 = getLogger("apns");
146818
- var APNS_HOST_SANDBOX = "api.sandbox.push.apple.com";
146819
- var APNS_MAX_PAYLOAD_BYTES = 4096;
146820
- var JWT_TTL_SECONDS = 3e3;
146821
- var DEAD_TOKEN_REASONS = /* @__PURE__ */ new Set([
146822
- "BadDeviceToken",
146823
- "DeviceTokenNotForTopic",
146824
- "Unregistered",
146825
- "ExpiredToken"
146826
- ]);
146827
- function base64url3(input) {
146828
- return Buffer.from(input).toString("base64url");
146829
- }
146830
- function readApnsCredentialsFromEnv(env = process.env) {
146831
- const key = env.APNS_KEY;
146832
- if (!key || key.trim().length === 0) return null;
146833
- const keyId = env.APNS_KEY_ID?.trim();
146834
- const teamId = env.APNS_TEAM_ID?.trim();
146835
- const bundleId = env.APNS_BUNDLE_ID?.trim();
146836
- if (!keyId || !teamId || !bundleId) return null;
146837
- const host = env.APNS_HOST ?? APNS_HOST_SANDBOX;
146838
- return { key, keyId, teamId, bundleId, host };
146839
- }
146840
- function describeMissingApnsCredentials(env = process.env) {
146841
- if (!env.APNS_KEY || env.APNS_KEY.trim().length === 0) {
146842
- return "APNS_KEY is not set, so Live Activity push is disabled. Set it to the p8 key contents (not a path) to enable it.";
146843
- }
146844
- const missing = [
146845
- ["APNS_KEY_ID", env.APNS_KEY_ID],
146846
- ["APNS_TEAM_ID", env.APNS_TEAM_ID],
146847
- ["APNS_BUNDLE_ID", env.APNS_BUNDLE_ID]
146848
- ].filter(([, value]) => !value || value.trim().length === 0).map(([name]) => name);
146849
- if (missing.length === 0) return null;
146850
- return `APNS_KEY is set but ${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} not, so Live Activity push is disabled. Under launchd, APNS_KEY_ID is derived from the AuthKey_<keyId>.p8 filename; the team and bundle ids must be set explicitly.`;
146851
- }
146852
- var ApnsClient = class {
146853
- constructor(creds) {
146854
- this.creds = creds;
146855
- }
146856
- creds;
146857
- session = null;
146858
- cachedJwt = null;
146859
- /**
146860
- * The `apns-topic` for Live Activity pushes.
146861
- *
146862
- * The `.push-type.liveactivity` suffix is mandatory and is why the signing key
146863
- * must be Team Scoped (All Topics) — a key scoped to the bundle id alone
146864
- * cannot sign this topic.
146865
- */
146866
- get topic() {
146867
- return `${this.creds.bundleId}.push-type.liveactivity`;
146868
- }
146869
- /**
146870
- * Mint or reuse the provider JWT.
146871
- *
146872
- * ES256 over the p8 key. Cached until shortly before expiry: Apple rejects a
146873
- * token older than an hour, but minting one per request is wasteful and can
146874
- * trip APNs' provider-token-update throttle.
146875
- */
146876
- getJwt(now = Date.now()) {
146877
- const nowSeconds = Math.floor(now / 1e3);
146878
- if (this.cachedJwt && this.cachedJwt.expiresAt > nowSeconds + 60) {
146879
- return this.cachedJwt.token;
146880
- }
146881
- const header = base64url3(JSON.stringify({ alg: "ES256", kid: this.creds.keyId, typ: "JWT" }));
146882
- const payload = base64url3(JSON.stringify({ iss: this.creds.teamId, iat: nowSeconds }));
146883
- const signingInput = `${header}.${payload}`;
146884
- const signature = (0, import_node_crypto3.createSign)("SHA256").update(signingInput).sign({ key: this.creds.key, dsaEncoding: "ieee-p1363" });
146885
- const token = `${signingInput}.${base64url3(signature)}`;
146886
- this.cachedJwt = { token, expiresAt: nowSeconds + JWT_TTL_SECONDS };
146887
- return token;
146888
- }
146889
- /**
146890
- * Reuse one HTTP/2 session across sends.
146891
- *
146892
- * APNs expects a long-lived connection; a fresh TLS handshake per push is slow
146893
- * and Apple treats connection churn as abuse.
146894
- */
146895
- getSession() {
146896
- if (this.session && !this.session.closed && !this.session.destroyed) {
146897
- return this.session;
146898
- }
146899
- const session = (0, import_node_http2.connect)(`https://${this.creds.host}`);
146900
- session.on("error", (err) => {
146901
- log4.warn("apns.session_error", { event: "apns.session_error", err: String(err) });
146902
- });
146903
- this.session = session;
146904
- return session;
146905
- }
146906
- /**
146907
- * Send one push.
146908
- *
146909
- * Resolves with a result rather than rejecting on an APNs rejection: a
146910
- * rejected push is an expected outcome the caller must act on (retire the
146911
- * token), not an exception. Only a genuinely unexpected local failure throws,
146912
- * and the caller logs it.
146913
- */
146914
- async send(args) {
146915
- const body = Buffer.from(JSON.stringify(args.payload), "utf-8");
146916
- if (body.byteLength > APNS_MAX_PAYLOAD_BYTES) {
146917
- throw new Error(
146918
- `APNs payload is ${body.byteLength} bytes, over the ${APNS_MAX_PAYLOAD_BYTES} byte limit`
146919
- );
146920
- }
146921
- const session = this.getSession();
146922
- const headers = {
146923
- [import_node_http2.constants.HTTP2_HEADER_METHOD]: "POST",
146924
- [import_node_http2.constants.HTTP2_HEADER_PATH]: `/3/device/${args.deviceToken}`,
146925
- [import_node_http2.constants.HTTP2_HEADER_AUTHORIZATION]: `bearer ${this.getJwt()}`,
146926
- "apns-push-type": "liveactivity",
146927
- "apns-topic": this.topic,
146928
- "apns-priority": String(args.priority ?? 10),
146929
- ...args.expirationSeconds != null && {
146930
- "apns-expiration": String(args.expirationSeconds)
146931
- },
146932
- [import_node_http2.constants.HTTP2_HEADER_CONTENT_TYPE]: "application/json",
146933
- [import_node_http2.constants.HTTP2_HEADER_CONTENT_LENGTH]: String(body.byteLength)
146934
- };
146935
- return new Promise((resolve4, reject) => {
146936
- const req = session.request(headers);
146937
- req.setTimeout(args.timeoutMs ?? 1e4, () => {
146938
- req.close(import_node_http2.constants.NGHTTP2_CANCEL);
146939
- resolve4({ ok: false, status: 0, reason: "Timeout", tokenDead: false });
146940
- });
146941
- let status = 0;
146942
- req.on("response", (resHeaders) => {
146943
- status = Number(resHeaders[import_node_http2.constants.HTTP2_HEADER_STATUS] ?? 0);
146944
- });
146945
- const chunks = [];
146946
- req.on("data", (chunk) => chunks.push(chunk));
146947
- req.on("error", reject);
146948
- req.on("end", () => {
146949
- const raw2 = Buffer.concat(chunks).toString("utf-8");
146950
- let reason;
146951
- if (raw2.length > 0) {
146952
- try {
146953
- reason = JSON.parse(raw2).reason;
146954
- } catch {
146955
- reason = raw2.slice(0, 200);
146956
- }
146957
- }
146958
- resolve4({
146959
- ok: status === 200,
146960
- status,
146961
- reason,
146962
- tokenDead: reason != null && DEAD_TOKEN_REASONS.has(reason)
146963
- });
146964
- });
146965
- req.end(body);
146966
- });
146967
- }
146968
- /** Close the shared connection. Called on server shutdown. */
146969
- close() {
146970
- this.session?.close();
146971
- this.session = null;
146972
- }
146973
- };
146974
-
146975
146994
  // src/services/push/expoPushSender.ts
146976
146995
  init_logger();
146977
146996
  var log5 = getLogger("expo-push");
@@ -149290,6 +149309,7 @@ var StreamerServer = class {
149290
149309
  cache: () => this.cache,
149291
149310
  cacheMonitor: () => this.cacheMonitor,
149292
149311
  pushRepo: () => this.pushRepo,
149312
+ liveActivityPushEnabled: () => this.liveActivityNotifier !== null,
149293
149313
  devicesRepo: () => this.devicesRepo,
149294
149314
  projectsRepo: () => this.projectsRepo,
149295
149315
  conversationsRepo: () => this.conversationsRepo,