@ganju/utils 0.0.6 → 0.0.8

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.
@@ -1 +1 @@
1
- {"version":3,"file":"sources.d.ts","sourceRoot":"","sources":["../src/sources.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,MAAM;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,aAAa,CAAC;IAC/C,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,oBAAoB,GAAI,YAAY,MAAM,CAAC,YAAY,CAAC,KAAG,OAEb,CAAC;AAE5D,eAAO,MAAM,uBAAuB,GAClC,UAAU;IAAE,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GAAG,IAAI,GAAG,SAAS,KAC1D,OAEwB,CAAC;AAE5B,eAAO,MAAM,YAAY,GAAI,KAAK,MAAM,KAAG,MAAM,GAAG,IAGnD,CAAC;AAEF,eAAO,MAAM,wBAAwB,GACnC,KAAK,kBAAkB,EACvB,YAAY,MAAM,EAClB,aAAa,MAAM,KAClB,MAGF,CAAC;AAEF,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;CACb;AAKD,eAAO,MAAM,sBAAsB,GACjC,SAAS,MAAM,EAAE,EACjB,KAAK,kBAAkB,EACvB,UAAU;IAAE,cAAc,CAAC,EAAE,MAAM,CAAA;CAAE,KACpC,YAAY,EAwBd,CAAC;AAEF,eAAO,MAAM,uBAAuB,GAClC,SAAS,MAAM,EAAE,EACjB,KAAK,kBAAkB,KACtB,MAmBF,CAAC"}
1
+ {"version":3,"file":"sources.d.ts","sourceRoot":"","sources":["../src/sources.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,MAAM;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,GAAG,SAAS,GAAG,aAAa,CAAC;IAC/C,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,EAAE,MAAM,CAAC;CACnB;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,oBAAoB,GAC/B,YAAY,MAAM,CAAC,YAAY,CAAC,KAC/B,OAEwD,CAAC;AAE5D,eAAO,MAAM,uBAAuB,GAClC,UAAU;IAAE,UAAU,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GAAG,IAAI,GAAG,SAAS,KAC1D,OAEwB,CAAC;AAE5B,eAAO,MAAM,YAAY,GAAI,KAAK,MAAM,KAAG,MAAM,GAAG,IAGnD,CAAC;AAEF,eAAO,MAAM,wBAAwB,GACnC,KAAK,kBAAkB,EACvB,YAAY,MAAM,EAClB,aAAa,MAAM,KAClB,MAGF,CAAC;AAEF,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;CACb;AAKD,eAAO,MAAM,sBAAsB,GACjC,SAAS,MAAM,EAAE,EACjB,KAAK,kBAAkB,EACvB,UAAU;IAAE,cAAc,CAAC,EAAE,MAAM,CAAA;CAAE,KACpC,YAAY,EA2Bd,CAAC;AAEF,eAAO,MAAM,uBAAuB,GAClC,SAAS,MAAM,EAAE,EACjB,KAAK,kBAAkB,KACtB,MAmBF,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"vendorError.d.ts","sourceRoot":"","sources":["../src/vendorError.ts"],"names":[],"mappings":"AASA,eAAO,MAAM,mBAAmB,GAAI,MAAM,OAAO,EAAE,UAAU,MAAM,KAAG,MASrE,CAAC"}
1
+ {"version":3,"file":"vendorError.d.ts","sourceRoot":"","sources":["../src/vendorError.ts"],"names":[],"mappings":"AASA,eAAO,MAAM,mBAAmB,GAC9B,MAAM,OAAO,EACb,UAAU,MAAM,KACf,MASF,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ganju/utils",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "description": "Shared constants, schemas and helpers for Ganju",
5
5
  "license": "Apache-2.0",
6
6
  "author": "MontoyaAndres <andresmontoyafcb@gmail.com>",
package/src/attachment.ts CHANGED
@@ -28,7 +28,8 @@ export interface ResolvedAttachment {
28
28
  }
29
29
 
30
30
  export type ResolveAttachmentResult =
31
- { ok: true; attachment: ResolvedAttachment } | { ok: false; error: string };
31
+ | { ok: true; attachment: ResolvedAttachment }
32
+ | { ok: false; error: string };
32
33
 
33
34
  /**
34
35
  * Resolve one resource's attachment bytes.
package/src/chunking.ts CHANGED
@@ -37,7 +37,9 @@ export const splitRecursive = (
37
37
  }
38
38
  if (current) chunks.push(current);
39
39
  return chunks.flatMap(chunk =>
40
- chunk.length > target ? splitRecursive(chunk, target, separators) : [chunk]
40
+ chunk.length > target
41
+ ? splitRecursive(chunk, target, separators)
42
+ : [chunk]
41
43
  );
42
44
  }
43
45
 
@@ -104,7 +106,9 @@ export const prepareChunks = (
104
106
  if (documents && documents.length > 0) {
105
107
  const out: PreparedChunk[] = [];
106
108
  documents.forEach((doc, docIndex) => {
107
- const body = (doc.pageContent || '').replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '').trim();
109
+ const body = (doc.pageContent || '')
110
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')
111
+ .trim();
108
112
  if (!body) return;
109
113
  const text = docIndex === 0 && header ? `${header}\n\n${body}` : body;
110
114
  const pieces = chunkText(text, options);
@@ -122,7 +126,9 @@ export const prepareChunks = (
122
126
  return out;
123
127
  }
124
128
 
125
- const body = (fallbackContent || '').replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '').trim();
129
+ const body = (fallbackContent || '')
130
+ .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')
131
+ .trim();
126
132
  const fullText = [header, body].filter(Boolean).join('\n\n');
127
133
  return chunkText(fullText, options).map(content => ({
128
134
  content,
package/src/coerce.ts CHANGED
@@ -3,7 +3,10 @@ export const toStringArray = (input: unknown): string[] => {
3
3
  return input.map(s => String(s).trim()).filter(Boolean);
4
4
  }
5
5
  if (typeof input === 'string' && input.trim()) {
6
- return input.split(',').map(s => s.trim()).filter(Boolean);
6
+ return input
7
+ .split(',')
8
+ .map(s => s.trim())
9
+ .filter(Boolean);
7
10
  }
8
11
  return [];
9
12
  };
package/src/constants.ts CHANGED
@@ -1811,7 +1811,8 @@ const PLAN_PRO = 'PRO' as 'PRO';
1811
1811
  const PLAN_ENTERPRISE = 'ENTERPRISE' as 'ENTERPRISE';
1812
1812
  const PLANS = [PLAN_FREE, PLAN_PRO, PLAN_ENTERPRISE];
1813
1813
 
1814
- // Mirror Stripe's subscription statuses so the webhook can store them verbatim.
1814
+ // The provider's subscription statuses, mirrored so the webhook can store them
1815
+ // verbatim.
1815
1816
  const SUBSCRIPTION_STATUS_ACTIVE = 'active' as 'active';
1816
1817
  const SUBSCRIPTION_STATUS_TRIALING = 'trialing' as 'trialing';
1817
1818
  const SUBSCRIPTION_STATUS_PAST_DUE = 'past_due' as 'past_due';
@@ -1891,6 +1892,30 @@ const PRICING_SHARED_KEY_HARD_CAP = 100_000;
1891
1892
  // so $2 is now roughly break-even. Raising CHUNK_TARGET_CHARS would tip it
1892
1893
  // positive without touching this number.
1893
1894
  const PRICING_EMBEDDED_PER_GB_USD = 2;
1895
+ // Custom-tool invocations included per month, and the rate beyond them.
1896
+ //
1897
+ // "Tool call" here means one dispatch into a user's own script on Workers for
1898
+ // Platforms — NOT a call to a shipped integration or a proxied server. Those
1899
+ // cost us one screened fetch from a Worker we already pay for, which is why they
1900
+ // stay bundled; this one runs the customer's code on our infrastructure, and is
1901
+ // the first cost axis a user can turn against us rather than merely consume.
1902
+ //
1903
+ // Cloudflare bills the whole chain (dispatcher → user script → outbound worker)
1904
+ // as ONE request and counts CPU across all three, so a call costs ~$2.30 per
1905
+ // million at the per-script CPU ceiling. $5/M is ~2x that, and 1,000,000 is far
1906
+ // past any real month: the heaviest worked customer in the cost model makes
1907
+ // 800,000, and a single artifact pinned at its rate limit for a full month
1908
+ // manages ~2.6 million.
1909
+ const PRICING_INCLUDED_TOOL_CALLS = 1_000_000;
1910
+ const PRICING_TOOL_CALL_PER_M_USD = 5;
1911
+ // Abuse backstop on custom-tool invocations — the compute analogue of
1912
+ // PRICING_SHARED_KEY_HARD_CAP, and needed for the same reason: past a point,
1913
+ // usage stops looking like a customer and starts looking like a runaway loop or
1914
+ // a stolen credential. 20x the included allowance is ~$46 of compute against
1915
+ // ~$95 billed, and takes eight artifacts held at their rate limit for a month to
1916
+ // reach. Enterprise is null — the cost model's own enterprise example makes 20
1917
+ // million calls a month, so a fixed ceiling there would fight a real customer.
1918
+ const PRICING_TOOL_CALL_HARD_CAP = 20_000_000;
1894
1919
  const PRICING_CUSTOM_DOMAIN_USD = 15;
1895
1920
 
1896
1921
  interface PlanLimits {
@@ -1934,6 +1959,20 @@ interface PlanLimits {
1934
1959
  // keeps that from becoming an unbounded tool list on the plan that runs on our
1935
1960
  // model key. `null` = no limit.
1936
1961
  maxHttpEndpointsPerArtifact: number | null;
1962
+ // Monthly allowance of CUSTOM-TOOL invocations — dispatches into the org's own
1963
+ // code. A billing threshold like includedSharedMessages, not a block: past it
1964
+ // paid plans keep running and meter at the per-million rate.
1965
+ //
1966
+ // Only custom code counts. A shipped integration or a proxied server costs one
1967
+ // screened fetch, so metering those would bill a customer for something that
1968
+ // rounds to zero and make the tool list a thing to ration.
1969
+ includedToolCalls: number;
1970
+ // Absolute stop on custom-tool invocations, regardless of billing. The
1971
+ // compute-side twin of sharedKeyHardCap, and the only limit on this axis that
1972
+ // an infinite loop inside someone's tool actually meets — the per-script CPU
1973
+ // ceiling bounds one call, the per-artifact rate limiter bounds calls per
1974
+ // minute, and this bounds the month. `null` = no backstop.
1975
+ toolCallHardCap: number | null;
1937
1976
  // Display-only allowances included in the plan (what overage is measured
1938
1977
  // against). Not used for blocking.
1939
1978
  includedMessages: number;
@@ -1967,6 +2006,15 @@ const PLAN_LIMITS: Record<
1967
2006
  canUseCustomLlm: false,
1968
2007
  canUseCustomCode: false,
1969
2008
  maxHttpEndpointsPerArtifact: 3,
2009
+ // Free can't deploy custom code, so the only way to have a running script on
2010
+ // this plan is to have DOWNGRADED with one already published — a paid org
2011
+ // keeps its versions and their bundles. Those tools go on serving, because
2012
+ // killing a customer's live integrations the moment a card fails is a worse
2013
+ // failure than serving a bounded number of calls while they fix it. This is
2014
+ // that bound: real breathing room, ~$0.02 of compute, and no overage path
2015
+ // off Free to turn it into a bill.
2016
+ includedToolCalls: 0,
2017
+ toolCallHardCap: 10_000,
1970
2018
  includedMessages: 100,
1971
2019
  includedEmbeddedBytes: 5 * MB
1972
2020
  },
@@ -1988,6 +2036,8 @@ const PLAN_LIMITS: Record<
1988
2036
  canUseCustomLlm: true,
1989
2037
  canUseCustomCode: true,
1990
2038
  maxHttpEndpointsPerArtifact: null,
2039
+ includedToolCalls: PRICING_INCLUDED_TOOL_CALLS,
2040
+ toolCallHardCap: PRICING_TOOL_CALL_HARD_CAP,
1991
2041
  includedMessages: PRICING_INCLUDED_MESSAGES,
1992
2042
  includedEmbeddedBytes: PRICING_INCLUDED_EMBEDDED_GB * GB
1993
2043
  },
@@ -2005,6 +2055,12 @@ const PLAN_LIMITS: Record<
2005
2055
  canUseCustomLlm: true,
2006
2056
  canUseCustomCode: true,
2007
2057
  maxHttpEndpointsPerArtifact: null,
2058
+ includedToolCalls: PRICING_INCLUDED_TOOL_CALLS,
2059
+ // No backstop: an Enterprise contract is negotiated against real volume, and
2060
+ // the reference deal in the cost model runs 20 million calls a month — the
2061
+ // exact number a Pro-shaped ceiling would refuse. Raise the ceiling by
2062
+ // contract; don't make one plan's abuse limit another plan's product limit.
2063
+ toolCallHardCap: null,
2008
2064
  includedMessages: PRICING_INCLUDED_MESSAGES,
2009
2065
  includedEmbeddedBytes: PRICING_INCLUDED_EMBEDDED_GB * GB
2010
2066
  }
@@ -2024,21 +2080,46 @@ const PLAN_FEATURE_HTTP_ENDPOINT = 'httpEndpoint' as 'httpEndpoint';
2024
2080
  const PLAN_FEATURE_RAW_STORAGE = 'rawStorage' as 'rawStorage';
2025
2081
  const PLAN_FEATURE_EMBEDDED_STORAGE = 'embeddedStorage' as 'embeddedStorage';
2026
2082
  const PLAN_FEATURE_MESSAGE = 'message' as 'message';
2083
+ const PLAN_FEATURE_TOOL_CALL = 'toolCall' as 'toolCall';
2027
2084
 
2028
2085
  // Stable code returned on a quota block so clients can branch on it (402).
2029
2086
  const PLAN_LIMIT_ERROR_CODE = 'PLAN_LIMIT_EXCEEDED';
2030
2087
 
2031
- // Stripe Billing Meter event names. The metering cron reports per-period
2032
- // OVERAGE (usage above the plan's included allowance) to these meters; the
2033
- // meters' prices on the subscription turn that into charges. Embedded storage
2034
- // is reported in whole MB, messages as a raw count.
2088
+ // Billing meter event names. The metering cron reports per-period OVERAGE
2089
+ // (usage above the plan's included allowance) to these meters; the meters'
2090
+ // prices on the subscribed product turn that into charges. Embedded storage is
2091
+ // reported in whole MB, messages as a raw count.
2092
+ //
2093
+ // Each name must match the filter on its meter exactly. A mismatch is not an
2094
+ // error at either end — the event is accepted, matches no meter, and the usage
2095
+ // is silently unbilled.
2035
2096
  //
2036
2097
  // Messages report to two separate meters because the two kinds of turn bill at
2037
2098
  // different rates — a turn on the org's own key is a platform fee, a turn on our
2038
2099
  // model is inference we bought. One meter can't price both.
2039
- const STRIPE_METER_MESSAGES = 'ganju_channel_messages';
2040
- const STRIPE_METER_SHARED_MESSAGES = 'ganju_shared_messages';
2041
- const STRIPE_METER_EMBEDDED = 'ganju_embedded_storage';
2100
+ const BILLING_METER_MESSAGES = 'ganju_channel_messages';
2101
+ const BILLING_METER_SHARED_MESSAGES = 'ganju_shared_messages';
2102
+ const BILLING_METER_EMBEDDED = 'ganju_embedded_storage';
2103
+ // Custom-tool invocations, reported as a raw count of the calls above the
2104
+ // included allowance and priced per call at $5/1,000,000. Metered prices are
2105
+ // per single unit, so a partial block is billed as the fraction it is — a
2106
+ // customer 2.5 million past the allowance owes $12.50, which is what every page
2107
+ // of ours quotes.
2108
+ const BILLING_METER_TOOL_CALLS = 'ganju_custom_tool_calls';
2109
+
2110
+ // The metadata property every meter aggregates (`sum` over it). Ingested events
2111
+ // carry the increment under this key, so it has to agree with the aggregation
2112
+ // configured on all four meters — which is why it lives beside their names
2113
+ // rather than at the call site.
2114
+ const BILLING_METER_UNITS_KEY = 'units';
2115
+
2116
+ // Polar API hosts. Sandbox is a separate host rather than a mode flag, so the
2117
+ // account, products, meters and webhook secret there are all distinct from
2118
+ // production's — which is what `POLAR_SERVER` selects between.
2119
+ const POLAR_API_BASE = 'https://api.polar.sh';
2120
+ const POLAR_SANDBOX_API_BASE = 'https://sandbox-api.polar.sh';
2121
+ const POLAR_SERVER_SANDBOX = 'sandbox' as 'sandbox';
2122
+ const POLAR_SERVER_PRODUCTION = 'production' as 'production';
2042
2123
 
2043
2124
  // Legal documents a user accepts, and the version they're on. Bump the version
2044
2125
  // when the document changes materially — existing users are then re-prompted,
@@ -2047,7 +2128,7 @@ const STRIPE_METER_EMBEDDED = 'ganju_embedded_storage';
2047
2128
  const CONSENT_DOCUMENT_TERMS = 'terms' as 'terms';
2048
2129
  const CONSENT_DOCUMENT_PRIVACY = 'privacy' as 'privacy';
2049
2130
  const CONSENT_DOCUMENTS = [CONSENT_DOCUMENT_TERMS, CONSENT_DOCUMENT_PRIVACY];
2050
- const CONSENT_CURRENT_VERSION = '2026-08-01';
2131
+ const CONSENT_CURRENT_VERSION = '2026-08-31';
2051
2132
 
2052
2133
  const CONSENT_SOURCE_SIGNUP = 'signup' as 'signup';
2053
2134
  const CONSENT_SOURCE_REACCEPT = 'reaccept' as 'reaccept';
@@ -2087,6 +2168,40 @@ const CRON_HOURLY = '0 * * * *';
2087
2168
  const CRON_ERROR_ALERTS = '*/15 * * * *';
2088
2169
 
2089
2170
  const ALERT_STATE_KEY_ERROR_LOG = 'error_log';
2171
+ // Custom-tool usage alerting, keyed per organization as `tool_calls:<id>`. The
2172
+ // monthly hard cap bounds what abuse can spend; these thresholds are what say
2173
+ // something is happening NOW, while there is still a month left to spend.
2174
+ const ALERT_STATE_KEY_TOOL_CALLS = 'tool_calls';
2175
+ // Calls in a single hour. The per-tool rate limit allows 3,600/hour, and a month
2176
+ // of the included million sustained is ~1,370/hour — so five figures in one hour
2177
+ // is either many artifacts at full tilt or something that should be looked at.
2178
+ const ALERT_TOOL_CALL_SURGE = 10_000;
2179
+ // The share of the abuse backstop at which the wall is close enough to warn
2180
+ // about. Half a month's ceiling reached is worth an email even if the rate is
2181
+ // unremarkable, because the alternative is a customer meeting it silently.
2182
+ const ALERT_TOOL_CALL_CAP_FRACTION = 0.5;
2183
+ // How long to stay quiet about one organization after alerting on it. Without
2184
+ // this a busy month is an hourly email, and an hourly email is a muted one.
2185
+ const ALERT_TOOL_CALL_COOLDOWN_HOURS = 6;
2186
+ // Organizations itemised in one digest.
2187
+ const ALERT_TOOL_CALL_MAX_ROWS = 25;
2188
+
2189
+ // The "stop these tools" link the usage digest carries.
2190
+ //
2191
+ // It exists because the alert travels and the response did not: every other
2192
+ // containment step needs a shell holding the production database URL, so an
2193
+ // abuse notice arriving away from a desk was one nobody could act on for hours.
2194
+ //
2195
+ // Narrow on purpose. It disables the organization's custom-code installs — the
2196
+ // tools stop registering at boot, the code, versions and settings all survive,
2197
+ // and any owner can switch them back on. It cannot delete a script, touch a
2198
+ // credential, or reach anything else.
2199
+ const CONTAINMENT_TOKEN_VERSION = '1';
2200
+ const CONTAINMENT_PURPOSE_DISABLE_CUSTOM_CODE = 'disable-custom-code';
2201
+ // Long enough to still work when the mail is read a few hours late, short enough
2202
+ // that a forwarded thread or an archived inbox is not a way in.
2203
+ const CONTAINMENT_TOKEN_TTL_MS = 12 * 60 * 60 * 1000;
2204
+ const CONTAINMENT_PATH = '/containment';
2090
2205
  // Only alert on genuine server failures. 4xx rows are expected client errors
2091
2206
  // (validation, not-found, quota) and would drown the signal.
2092
2207
  const ALERT_MIN_STATUS = 500;
@@ -2131,6 +2246,9 @@ export const constants = {
2131
2246
  PRICING_INCLUDED_EMBEDDED_GB,
2132
2247
  PRICING_MESSAGE_PER_1K_USD,
2133
2248
  PRICING_EMBEDDED_PER_GB_USD,
2249
+ PRICING_INCLUDED_TOOL_CALLS,
2250
+ PRICING_TOOL_CALL_PER_M_USD,
2251
+ PRICING_TOOL_CALL_HARD_CAP,
2134
2252
  PRICING_CUSTOM_DOMAIN_USD,
2135
2253
  PLAN_FEATURE_ORGANIZATION,
2136
2254
  PLAN_FEATURE_PROJECT,
@@ -2144,10 +2262,17 @@ export const constants = {
2144
2262
  PLAN_FEATURE_RAW_STORAGE,
2145
2263
  PLAN_FEATURE_EMBEDDED_STORAGE,
2146
2264
  PLAN_FEATURE_MESSAGE,
2265
+ PLAN_FEATURE_TOOL_CALL,
2147
2266
  PLAN_LIMIT_ERROR_CODE,
2148
- STRIPE_METER_MESSAGES,
2149
- STRIPE_METER_SHARED_MESSAGES,
2150
- STRIPE_METER_EMBEDDED,
2267
+ BILLING_METER_MESSAGES,
2268
+ BILLING_METER_SHARED_MESSAGES,
2269
+ BILLING_METER_EMBEDDED,
2270
+ BILLING_METER_TOOL_CALLS,
2271
+ BILLING_METER_UNITS_KEY,
2272
+ POLAR_API_BASE,
2273
+ POLAR_SANDBOX_API_BASE,
2274
+ POLAR_SERVER_SANDBOX,
2275
+ POLAR_SERVER_PRODUCTION,
2151
2276
  CONSENT_DOCUMENT_TERMS,
2152
2277
  CONSENT_DOCUMENT_PRIVACY,
2153
2278
  CONSENT_DOCUMENTS,
@@ -2161,6 +2286,15 @@ export const constants = {
2161
2286
  CRON_HOURLY,
2162
2287
  CRON_ERROR_ALERTS,
2163
2288
  ALERT_STATE_KEY_ERROR_LOG,
2289
+ ALERT_STATE_KEY_TOOL_CALLS,
2290
+ ALERT_TOOL_CALL_SURGE,
2291
+ ALERT_TOOL_CALL_CAP_FRACTION,
2292
+ ALERT_TOOL_CALL_COOLDOWN_HOURS,
2293
+ ALERT_TOOL_CALL_MAX_ROWS,
2294
+ CONTAINMENT_TOKEN_VERSION,
2295
+ CONTAINMENT_PURPOSE_DISABLE_CUSTOM_CODE,
2296
+ CONTAINMENT_TOKEN_TTL_MS,
2297
+ CONTAINMENT_PATH,
2164
2298
  ALERT_MIN_STATUS,
2165
2299
  ALERT_MAX_ROWS,
2166
2300
  ALERT_MAX_GROUPS,
@@ -0,0 +1,146 @@
1
+ import {
2
+ bytesToBase64,
3
+ toBase64Url,
4
+ fromBase64Url,
5
+ base64ToBytes
6
+ } from './base64';
7
+ import { constants } from './constants';
8
+
9
+ // The capability carried by the "stop these tools" link in a usage alert.
10
+ //
11
+ // The alert reaches whoever is on call wherever they are; until this existed the
12
+ // response did not — every containment step needed a shell holding the
13
+ // production database URL, so an abuse notice arriving away from a desk was an
14
+ // abuse notice nobody could act on for hours.
15
+ //
16
+ // A signed value rather than a row, for the same reason the tool token is one:
17
+ // there is nothing to revoke. The link lives minutes, does one narrow and fully
18
+ // reversible thing, and names in its own payload the only organization it can do
19
+ // it to.
20
+ //
21
+ // Two properties this must have, and both are about it travelling through email:
22
+ //
23
+ // - **Purpose-bound.** The payload carries `p`, checked on verify, so a token
24
+ // signed with this deployment's secret for one job can never be replayed as
25
+ // another. Domain separation is what makes sharing one secret safe.
26
+ // - **Not a GET.** Mail clients, link scanners and chat previews fetch URLs
27
+ // without being asked. The link opens a page; a form POST on that page is what
28
+ // acts. A capability that fires on preview is a capability someone else holds.
29
+ export interface ContainmentTokenPayload {
30
+ // Token format version, so a future change to the payload is rejected rather
31
+ // than misread.
32
+ v: string;
33
+ // Purpose. One value today; present so there can be a second.
34
+ p: string;
35
+ organizationId: string;
36
+ iat: number;
37
+ exp: number;
38
+ }
39
+
40
+ const encoder = new TextEncoder();
41
+
42
+ const importKey = (secret: string): Promise<CryptoKey> =>
43
+ crypto.subtle.importKey(
44
+ 'raw',
45
+ encoder.encode(secret),
46
+ { name: 'HMAC', hash: 'SHA-256' },
47
+ false,
48
+ ['sign', 'verify']
49
+ );
50
+
51
+ /**
52
+ * Mint a containment link token for one organization.
53
+ *
54
+ * The lifetime is deliberately short. This is not a login: it is a thing you act
55
+ * on while reading the mail that carried it, and an expiry measured in hours
56
+ * means a forwarded thread or an archived inbox stops being a way in.
57
+ */
58
+ export const mintContainmentToken = async (
59
+ organizationId: string,
60
+ secret: string,
61
+ issuedAt: number = Date.now(),
62
+ ttlMs: number = constants.CONTAINMENT_TOKEN_TTL_MS
63
+ ): Promise<string> => {
64
+ const body: ContainmentTokenPayload = {
65
+ v: constants.CONTAINMENT_TOKEN_VERSION,
66
+ p: constants.CONTAINMENT_PURPOSE_DISABLE_CUSTOM_CODE,
67
+ organizationId,
68
+ iat: Math.floor(issuedAt / 1000),
69
+ // Rounded up so a stated lifetime is a floor rather than up to a second
70
+ // short of one.
71
+ exp: Math.ceil((issuedAt + ttlMs) / 1000)
72
+ };
73
+ const encodedPayload = toBase64Url(
74
+ bytesToBase64(encoder.encode(JSON.stringify(body)))
75
+ );
76
+ const key = await importKey(secret);
77
+ const signature = await crypto.subtle.sign(
78
+ 'HMAC',
79
+ key,
80
+ encoder.encode(encodedPayload)
81
+ );
82
+ return `${encodedPayload}.${toBase64Url(bytesToBase64(new Uint8Array(signature)))}`;
83
+ };
84
+
85
+ /**
86
+ * Verify a containment token and return its payload, or null when it is
87
+ * malformed, expired, of an unknown format version, minted for another purpose,
88
+ * or not signed by this deployment's secret.
89
+ *
90
+ * Null rather than a reason, so the page can answer every rejection identically:
91
+ * a link that says *why* it failed tells whoever found it what to change.
92
+ */
93
+ export const verifyContainmentToken = async (
94
+ token: string,
95
+ secret: string,
96
+ now: number = Date.now()
97
+ ): Promise<ContainmentTokenPayload | null> => {
98
+ const separator = token.lastIndexOf('.');
99
+ if (separator <= 0) return null;
100
+
101
+ const encodedPayload = token.slice(0, separator);
102
+ const signature = token.slice(separator + 1);
103
+
104
+ const key = await importKey(secret);
105
+ let valid: boolean;
106
+ try {
107
+ const signatureBytes = new Uint8Array(
108
+ base64ToBytes(fromBase64Url(signature))
109
+ );
110
+ valid = await crypto.subtle.verify(
111
+ 'HMAC',
112
+ key,
113
+ signatureBytes,
114
+ encoder.encode(encodedPayload)
115
+ );
116
+ } catch {
117
+ return null;
118
+ }
119
+ if (!valid) return null;
120
+
121
+ let parsed: unknown;
122
+ try {
123
+ parsed = JSON.parse(
124
+ new TextDecoder().decode(base64ToBytes(fromBase64Url(encodedPayload)))
125
+ );
126
+ } catch {
127
+ return null;
128
+ }
129
+
130
+ if (!parsed || typeof parsed !== 'object') return null;
131
+ const candidate = parsed as Partial<ContainmentTokenPayload>;
132
+ if (candidate.v !== constants.CONTAINMENT_TOKEN_VERSION) return null;
133
+ if (candidate.p !== constants.CONTAINMENT_PURPOSE_DISABLE_CUSTOM_CODE) {
134
+ return null;
135
+ }
136
+ if (
137
+ typeof candidate.organizationId !== 'string' ||
138
+ !candidate.organizationId
139
+ ) {
140
+ return null;
141
+ }
142
+ if (typeof candidate.exp !== 'number') return null;
143
+ if (candidate.exp * 1000 <= now) return null;
144
+
145
+ return candidate as ContainmentTokenPayload;
146
+ };
package/src/crypto.ts CHANGED
@@ -73,8 +73,12 @@ export const sha256Base64Url = async (input: string) => {
73
73
  );
74
74
  const bytes = new Uint8Array(digest);
75
75
  let binary = '';
76
- for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
77
- return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
76
+ for (let i = 0; i < bytes.length; i++)
77
+ binary += String.fromCharCode(bytes[i]);
78
+ return btoa(binary)
79
+ .replace(/\+/g, '-')
80
+ .replace(/\//g, '_')
81
+ .replace(/=+$/, '');
78
82
  };
79
83
 
80
84
  export const hmacSha256Hex = async (key: string, message: string) => {
package/src/getEnv.ts CHANGED
@@ -2,10 +2,7 @@ export interface EnvSource {
2
2
  env?: Record<string, unknown> | unknown;
3
3
  }
4
4
 
5
- export const getEnv = (
6
- source: EnvSource,
7
- key: string
8
- ): string | undefined => {
5
+ export const getEnv = (source: EnvSource, key: string): string | undefined => {
9
6
  const env = (source.env as Record<string, unknown> | undefined) || undefined;
10
7
  const value = env?.[key];
11
8
  if (typeof value === 'string') return value;
package/src/index.ts CHANGED
@@ -107,6 +107,11 @@ import {
107
107
  customCodePreviewUploadName
108
108
  } from './customCodeToken';
109
109
  import type { CustomCodeTokenPayload } from './customCodeToken';
110
+ import {
111
+ mintContainmentToken,
112
+ verifyContainmentToken
113
+ } from './containmentToken';
114
+ import type { ContainmentTokenPayload } from './containmentToken';
110
115
  import { oauthProviders } from './oauthProviders';
111
116
  import type { OAuthProviderConfig } from './oauthProviders';
112
117
  import { resolveAttachment } from './attachment';
@@ -276,6 +281,8 @@ export const utils = {
276
281
  customCodePreviewScriptName,
277
282
  customCodeUploadName,
278
283
  customCodePreviewUploadName,
284
+ mintContainmentToken,
285
+ verifyContainmentToken,
279
286
  oauthProviders,
280
287
  resolveAttachment,
281
288
  isExposedResource,
@@ -417,6 +424,7 @@ export type {
417
424
  CustomCodeSendFile,
418
425
  CustomCodeCreateResource,
419
426
  CustomCodeTokenPayload,
427
+ ContainmentTokenPayload,
420
428
  OAuthProviderConfig,
421
429
  AttachmentResource,
422
430
  ResolvedAttachment,
package/src/oauth.ts CHANGED
@@ -95,9 +95,7 @@ export const clearReauthMetadata = (
95
95
  return Object.keys(next).length > 0 ? next : null;
96
96
  };
97
97
 
98
- export const isCredentialNeedingReauth = (
99
- metadata: unknown
100
- ): boolean => {
98
+ export const isCredentialNeedingReauth = (metadata: unknown): boolean => {
101
99
  if (!metadata || typeof metadata !== 'object') return false;
102
100
  return (metadata as Record<string, unknown>).needsReauth === true;
103
101
  };
@@ -1,4 +1,6 @@
1
- export const parseHttpErrorMessage = async (response: Response): Promise<string> => {
1
+ export const parseHttpErrorMessage = async (
2
+ response: Response
3
+ ): Promise<string> => {
2
4
  const raw = await response.text();
3
5
  if (!raw) return `${response.status} ${response.statusText}`;
4
6
 
package/src/slackSend.ts CHANGED
@@ -18,17 +18,17 @@ export interface SlackSendRequest {
18
18
  operation: SlackOperation;
19
19
 
20
20
  // chat.postMessage / files.completeUploadExternal share these
21
- channel: string; // channel ID (C…/G…/D…) or name (#general)
22
- threadTs?: string; // reply in-thread when set
21
+ channel: string; // channel ID (C…/G…/D…) or name (#general)
22
+ threadTs?: string; // reply in-thread when set
23
23
 
24
24
  // post-message
25
- text?: string; // required for post-message
26
- mrkdwn?: boolean; // defaults to true on Slack's side
27
- blocks?: unknown[]; // optional Block Kit payload
25
+ text?: string; // required for post-message
26
+ mrkdwn?: boolean; // defaults to true on Slack's side
27
+ blocks?: unknown[]; // optional Block Kit payload
28
28
 
29
29
  // upload-file
30
- title?: string; // shown in the Slack file viewer
31
- initialComment?: string; // message body posted alongside the file
30
+ title?: string; // shown in the Slack file viewer
31
+ initialComment?: string; // message body posted alongside the file
32
32
  }
33
33
 
34
34
  // Wire protocol for sending a PROXIED (remote MCP) resource as a Slack file.
@@ -59,7 +59,7 @@ export interface SlackSendResponse {
59
59
  // file id. Empty string when Slack returns ok:true with no id (shouldn't
60
60
  // happen, but defensively typed).
61
61
  id: string;
62
- channel?: string; // resolved channel id
63
- ts?: string; // message timestamp (post-message only)
64
- permalink?: string; // file permalink (upload-file only)
62
+ channel?: string; // resolved channel id
63
+ ts?: string; // message timestamp (post-message only)
64
+ permalink?: string; // file permalink (upload-file only)
65
65
  }
package/src/sources.ts CHANGED
@@ -30,7 +30,9 @@ export interface ResourceUrlContext {
30
30
  * nothing to a browser. Formatting it as a web link produced a dead button in
31
31
  * whichever channel the answer went to.
32
32
  */
33
- export const isDownloadableSource = (sourceType: Source['sourceType']): boolean =>
33
+ export const isDownloadableSource = (
34
+ sourceType: Source['sourceType']
35
+ ): boolean =>
34
36
  sourceType === constants.RESOURCE_SOURCE_TYPE_FILE ||
35
37
  sourceType === constants.RESOURCE_SOURCE_TYPE_CUSTOM_CODE;
36
38
 
@@ -79,7 +81,10 @@ export const formatSourcesAsButtons = (
79
81
  source.pageNumber
80
82
  );
81
83
  return {
82
- text: truncateLabel(`${position} ${label}${pageSuffix}`, maxLabelLength),
84
+ text: truncateLabel(
85
+ `${position} ${label}${pageSuffix}`,
86
+ maxLabelLength
87
+ ),
83
88
  url
84
89
  };
85
90
  }
@@ -7,7 +7,10 @@
7
7
  // Error message turns a perfectly diagnosable failure into "[object Object]",
8
8
  // which is what every one of these call sites did until a real Gmail rejection
9
9
  // was put through them.
10
- export const describeVendorError = (body: unknown, fallback: string): string => {
10
+ export const describeVendorError = (
11
+ body: unknown,
12
+ fallback: string
13
+ ): string => {
11
14
  const error = (body as { error?: unknown } | null | undefined)?.error;
12
15
  if (typeof error === 'string' && error) return error;
13
16
  if (error && typeof error === 'object') {