@ganju/utils 0.0.6 → 0.0.9
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/attachment.d.ts.map +1 -1
- package/dist/chunking.d.ts.map +1 -1
- package/dist/chunking.js +9 -3
- package/dist/coerce.d.ts.map +1 -1
- package/dist/coerce.js +4 -1
- package/dist/constants.d.ts +27 -3
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +157 -22
- package/dist/containmentToken.d.ts +25 -0
- package/dist/containmentToken.d.ts.map +1 -0
- package/dist/containmentToken.js +81 -0
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +4 -1
- package/dist/getEnv.d.ts.map +1 -1
- package/dist/index.d.ts +34 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/llmBaseUrl.d.ts +39 -0
- package/dist/llmBaseUrl.d.ts.map +1 -0
- package/dist/llmBaseUrl.js +65 -0
- package/dist/localizeZodIssue.d.ts.map +1 -1
- package/dist/localizeZodIssue.js +6 -0
- package/dist/oauth.d.ts.map +1 -1
- package/dist/parseHttpError.d.ts.map +1 -1
- package/dist/schema.d.ts +2 -2
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +32 -8
- package/dist/sources.d.ts.map +1 -1
- package/dist/vendorError.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/attachment.ts +2 -1
- package/src/chunking.ts +9 -3
- package/src/coerce.ts +4 -1
- package/src/constants.ts +178 -22
- package/src/containmentToken.ts +146 -0
- package/src/crypto.ts +6 -2
- package/src/getEnv.ts +1 -4
- package/src/index.ts +16 -0
- package/src/llmBaseUrl.ts +65 -0
- package/src/localizeZodIssue.ts +6 -0
- package/src/oauth.ts +1 -3
- package/src/parseHttpError.ts +3 -1
- package/src/schema.ts +63 -11
- package/src/slackSend.ts +10 -10
- package/src/sources.ts +7 -2
- package/src/vendorError.ts +4 -1
package/src/constants.ts
CHANGED
|
@@ -622,8 +622,8 @@ const LLM_CATALOG: ReadonlyArray<{
|
|
|
622
622
|
}> = [
|
|
623
623
|
{
|
|
624
624
|
provider: LLM_PROVIDER_GOOGLE,
|
|
625
|
-
model: 'gemini-3-flash
|
|
626
|
-
label: 'Gemini 3 Flash'
|
|
625
|
+
model: 'gemini-3.8-flash',
|
|
626
|
+
label: 'Gemini 3.8 Flash'
|
|
627
627
|
},
|
|
628
628
|
{
|
|
629
629
|
provider: LLM_PROVIDER_GOOGLE,
|
|
@@ -632,13 +632,13 @@ const LLM_CATALOG: ReadonlyArray<{
|
|
|
632
632
|
},
|
|
633
633
|
{
|
|
634
634
|
provider: LLM_PROVIDER_OPENAI,
|
|
635
|
-
model: 'gpt-
|
|
636
|
-
label: 'GPT-
|
|
635
|
+
model: 'gpt-5.4-mini',
|
|
636
|
+
label: 'GPT-5.4 mini'
|
|
637
637
|
},
|
|
638
638
|
{
|
|
639
639
|
provider: LLM_PROVIDER_OPENAI,
|
|
640
|
-
model: 'gpt-
|
|
641
|
-
label: 'GPT-
|
|
640
|
+
model: 'gpt-5.5',
|
|
641
|
+
label: 'GPT-5.5'
|
|
642
642
|
},
|
|
643
643
|
{
|
|
644
644
|
provider: LLM_PROVIDER_ANTHROPIC,
|
|
@@ -647,16 +647,35 @@ const LLM_CATALOG: ReadonlyArray<{
|
|
|
647
647
|
},
|
|
648
648
|
{
|
|
649
649
|
provider: LLM_PROVIDER_ANTHROPIC,
|
|
650
|
-
model: 'claude-sonnet-
|
|
651
|
-
label: 'Claude Sonnet
|
|
650
|
+
model: 'claude-sonnet-5',
|
|
651
|
+
label: 'Claude Sonnet 5'
|
|
652
652
|
},
|
|
653
653
|
{
|
|
654
654
|
provider: LLM_PROVIDER_ANTHROPIC,
|
|
655
|
-
model: 'claude-opus-
|
|
656
|
-
label: 'Claude
|
|
655
|
+
model: 'claude-opus-5',
|
|
656
|
+
label: 'Claude Opus 5'
|
|
657
657
|
}
|
|
658
658
|
];
|
|
659
659
|
|
|
660
|
+
// The picker's escape hatch. Anything that speaks the OpenAI chat-completions
|
|
661
|
+
// protocol — a gateway, an aggregator, a self-hosted server — is reachable
|
|
662
|
+
// without a catalog row: the customer supplies the model id and the base URL,
|
|
663
|
+
// and the request goes out through the OpenAI adapter. Not a provider value, so
|
|
664
|
+
// it can never reach the database; the dashboard translates it into
|
|
665
|
+
// LLM_PROVIDER_OPENAI_COMPATIBLE on submit.
|
|
666
|
+
const LLM_CATALOG_CUSTOM_KEY = 'custom';
|
|
667
|
+
|
|
668
|
+
// Validation messages for an organization LLM's base URL. Constants rather than
|
|
669
|
+
// inline strings because localizeZodIssue keys its translations on the exact
|
|
670
|
+
// English text, so the two files have to name the same value. Both also carry a
|
|
671
|
+
// word matchStatus recognises ("required", "must be"): the update path re-checks
|
|
672
|
+
// the merged row and throws the message as a plain Error, and anything
|
|
673
|
+
// unrecognised there becomes an opaque 500 instead of a 400.
|
|
674
|
+
const LLM_BASE_URL_REQUIRED_MESSAGE =
|
|
675
|
+
'A base URL is required for an OpenAI-compatible model';
|
|
676
|
+
const LLM_BASE_URL_INVALID_MESSAGE =
|
|
677
|
+
'Base URL must be a public https:// address';
|
|
678
|
+
|
|
660
679
|
const LLM_SYSTEM_DEFAULT = 'SYSTEM_DEFAULT';
|
|
661
680
|
|
|
662
681
|
const MAX_TOOL_LOOPS = 8;
|
|
@@ -1811,7 +1830,8 @@ const PLAN_PRO = 'PRO' as 'PRO';
|
|
|
1811
1830
|
const PLAN_ENTERPRISE = 'ENTERPRISE' as 'ENTERPRISE';
|
|
1812
1831
|
const PLANS = [PLAN_FREE, PLAN_PRO, PLAN_ENTERPRISE];
|
|
1813
1832
|
|
|
1814
|
-
//
|
|
1833
|
+
// The provider's subscription statuses, mirrored so the webhook can store them
|
|
1834
|
+
// verbatim.
|
|
1815
1835
|
const SUBSCRIPTION_STATUS_ACTIVE = 'active' as 'active';
|
|
1816
1836
|
const SUBSCRIPTION_STATUS_TRIALING = 'trialing' as 'trialing';
|
|
1817
1837
|
const SUBSCRIPTION_STATUS_PAST_DUE = 'past_due' as 'past_due';
|
|
@@ -1891,6 +1911,30 @@ const PRICING_SHARED_KEY_HARD_CAP = 100_000;
|
|
|
1891
1911
|
// so $2 is now roughly break-even. Raising CHUNK_TARGET_CHARS would tip it
|
|
1892
1912
|
// positive without touching this number.
|
|
1893
1913
|
const PRICING_EMBEDDED_PER_GB_USD = 2;
|
|
1914
|
+
// Custom-tool invocations included per month, and the rate beyond them.
|
|
1915
|
+
//
|
|
1916
|
+
// "Tool call" here means one dispatch into a user's own script on Workers for
|
|
1917
|
+
// Platforms — NOT a call to a shipped integration or a proxied server. Those
|
|
1918
|
+
// cost us one screened fetch from a Worker we already pay for, which is why they
|
|
1919
|
+
// stay bundled; this one runs the customer's code on our infrastructure, and is
|
|
1920
|
+
// the first cost axis a user can turn against us rather than merely consume.
|
|
1921
|
+
//
|
|
1922
|
+
// Cloudflare bills the whole chain (dispatcher → user script → outbound worker)
|
|
1923
|
+
// as ONE request and counts CPU across all three, so a call costs ~$2.30 per
|
|
1924
|
+
// million at the per-script CPU ceiling. $5/M is ~2x that, and 1,000,000 is far
|
|
1925
|
+
// past any real month: the heaviest worked customer in the cost model makes
|
|
1926
|
+
// 800,000, and a single artifact pinned at its rate limit for a full month
|
|
1927
|
+
// manages ~2.6 million.
|
|
1928
|
+
const PRICING_INCLUDED_TOOL_CALLS = 1_000_000;
|
|
1929
|
+
const PRICING_TOOL_CALL_PER_M_USD = 5;
|
|
1930
|
+
// Abuse backstop on custom-tool invocations — the compute analogue of
|
|
1931
|
+
// PRICING_SHARED_KEY_HARD_CAP, and needed for the same reason: past a point,
|
|
1932
|
+
// usage stops looking like a customer and starts looking like a runaway loop or
|
|
1933
|
+
// a stolen credential. 20x the included allowance is ~$46 of compute against
|
|
1934
|
+
// ~$95 billed, and takes eight artifacts held at their rate limit for a month to
|
|
1935
|
+
// reach. Enterprise is null — the cost model's own enterprise example makes 20
|
|
1936
|
+
// million calls a month, so a fixed ceiling there would fight a real customer.
|
|
1937
|
+
const PRICING_TOOL_CALL_HARD_CAP = 20_000_000;
|
|
1894
1938
|
const PRICING_CUSTOM_DOMAIN_USD = 15;
|
|
1895
1939
|
|
|
1896
1940
|
interface PlanLimits {
|
|
@@ -1934,6 +1978,20 @@ interface PlanLimits {
|
|
|
1934
1978
|
// keeps that from becoming an unbounded tool list on the plan that runs on our
|
|
1935
1979
|
// model key. `null` = no limit.
|
|
1936
1980
|
maxHttpEndpointsPerArtifact: number | null;
|
|
1981
|
+
// Monthly allowance of CUSTOM-TOOL invocations — dispatches into the org's own
|
|
1982
|
+
// code. A billing threshold like includedSharedMessages, not a block: past it
|
|
1983
|
+
// paid plans keep running and meter at the per-million rate.
|
|
1984
|
+
//
|
|
1985
|
+
// Only custom code counts. A shipped integration or a proxied server costs one
|
|
1986
|
+
// screened fetch, so metering those would bill a customer for something that
|
|
1987
|
+
// rounds to zero and make the tool list a thing to ration.
|
|
1988
|
+
includedToolCalls: number;
|
|
1989
|
+
// Absolute stop on custom-tool invocations, regardless of billing. The
|
|
1990
|
+
// compute-side twin of sharedKeyHardCap, and the only limit on this axis that
|
|
1991
|
+
// an infinite loop inside someone's tool actually meets — the per-script CPU
|
|
1992
|
+
// ceiling bounds one call, the per-artifact rate limiter bounds calls per
|
|
1993
|
+
// minute, and this bounds the month. `null` = no backstop.
|
|
1994
|
+
toolCallHardCap: number | null;
|
|
1937
1995
|
// Display-only allowances included in the plan (what overage is measured
|
|
1938
1996
|
// against). Not used for blocking.
|
|
1939
1997
|
includedMessages: number;
|
|
@@ -1967,6 +2025,15 @@ const PLAN_LIMITS: Record<
|
|
|
1967
2025
|
canUseCustomLlm: false,
|
|
1968
2026
|
canUseCustomCode: false,
|
|
1969
2027
|
maxHttpEndpointsPerArtifact: 3,
|
|
2028
|
+
// Free can't deploy custom code, so the only way to have a running script on
|
|
2029
|
+
// this plan is to have DOWNGRADED with one already published — a paid org
|
|
2030
|
+
// keeps its versions and their bundles. Those tools go on serving, because
|
|
2031
|
+
// killing a customer's live integrations the moment a card fails is a worse
|
|
2032
|
+
// failure than serving a bounded number of calls while they fix it. This is
|
|
2033
|
+
// that bound: real breathing room, ~$0.02 of compute, and no overage path
|
|
2034
|
+
// off Free to turn it into a bill.
|
|
2035
|
+
includedToolCalls: 0,
|
|
2036
|
+
toolCallHardCap: 10_000,
|
|
1970
2037
|
includedMessages: 100,
|
|
1971
2038
|
includedEmbeddedBytes: 5 * MB
|
|
1972
2039
|
},
|
|
@@ -1988,6 +2055,8 @@ const PLAN_LIMITS: Record<
|
|
|
1988
2055
|
canUseCustomLlm: true,
|
|
1989
2056
|
canUseCustomCode: true,
|
|
1990
2057
|
maxHttpEndpointsPerArtifact: null,
|
|
2058
|
+
includedToolCalls: PRICING_INCLUDED_TOOL_CALLS,
|
|
2059
|
+
toolCallHardCap: PRICING_TOOL_CALL_HARD_CAP,
|
|
1991
2060
|
includedMessages: PRICING_INCLUDED_MESSAGES,
|
|
1992
2061
|
includedEmbeddedBytes: PRICING_INCLUDED_EMBEDDED_GB * GB
|
|
1993
2062
|
},
|
|
@@ -2005,6 +2074,12 @@ const PLAN_LIMITS: Record<
|
|
|
2005
2074
|
canUseCustomLlm: true,
|
|
2006
2075
|
canUseCustomCode: true,
|
|
2007
2076
|
maxHttpEndpointsPerArtifact: null,
|
|
2077
|
+
includedToolCalls: PRICING_INCLUDED_TOOL_CALLS,
|
|
2078
|
+
// No backstop: an Enterprise contract is negotiated against real volume, and
|
|
2079
|
+
// the reference deal in the cost model runs 20 million calls a month — the
|
|
2080
|
+
// exact number a Pro-shaped ceiling would refuse. Raise the ceiling by
|
|
2081
|
+
// contract; don't make one plan's abuse limit another plan's product limit.
|
|
2082
|
+
toolCallHardCap: null,
|
|
2008
2083
|
includedMessages: PRICING_INCLUDED_MESSAGES,
|
|
2009
2084
|
includedEmbeddedBytes: PRICING_INCLUDED_EMBEDDED_GB * GB
|
|
2010
2085
|
}
|
|
@@ -2024,21 +2099,46 @@ const PLAN_FEATURE_HTTP_ENDPOINT = 'httpEndpoint' as 'httpEndpoint';
|
|
|
2024
2099
|
const PLAN_FEATURE_RAW_STORAGE = 'rawStorage' as 'rawStorage';
|
|
2025
2100
|
const PLAN_FEATURE_EMBEDDED_STORAGE = 'embeddedStorage' as 'embeddedStorage';
|
|
2026
2101
|
const PLAN_FEATURE_MESSAGE = 'message' as 'message';
|
|
2102
|
+
const PLAN_FEATURE_TOOL_CALL = 'toolCall' as 'toolCall';
|
|
2027
2103
|
|
|
2028
2104
|
// Stable code returned on a quota block so clients can branch on it (402).
|
|
2029
2105
|
const PLAN_LIMIT_ERROR_CODE = 'PLAN_LIMIT_EXCEEDED';
|
|
2030
2106
|
|
|
2031
|
-
//
|
|
2032
|
-
//
|
|
2033
|
-
//
|
|
2034
|
-
//
|
|
2107
|
+
// Billing meter event names. The metering cron reports per-period OVERAGE
|
|
2108
|
+
// (usage above the plan's included allowance) to these meters; the meters'
|
|
2109
|
+
// prices on the subscribed product turn that into charges. Embedded storage is
|
|
2110
|
+
// reported in whole MB, messages as a raw count.
|
|
2111
|
+
//
|
|
2112
|
+
// Each name must match the filter on its meter exactly. A mismatch is not an
|
|
2113
|
+
// error at either end — the event is accepted, matches no meter, and the usage
|
|
2114
|
+
// is silently unbilled.
|
|
2035
2115
|
//
|
|
2036
2116
|
// Messages report to two separate meters because the two kinds of turn bill at
|
|
2037
2117
|
// different rates — a turn on the org's own key is a platform fee, a turn on our
|
|
2038
2118
|
// model is inference we bought. One meter can't price both.
|
|
2039
|
-
const
|
|
2040
|
-
const
|
|
2041
|
-
const
|
|
2119
|
+
const BILLING_METER_MESSAGES = 'ganju_channel_messages';
|
|
2120
|
+
const BILLING_METER_SHARED_MESSAGES = 'ganju_shared_messages';
|
|
2121
|
+
const BILLING_METER_EMBEDDED = 'ganju_embedded_storage';
|
|
2122
|
+
// Custom-tool invocations, reported as a raw count of the calls above the
|
|
2123
|
+
// included allowance and priced per call at $5/1,000,000. Metered prices are
|
|
2124
|
+
// per single unit, so a partial block is billed as the fraction it is — a
|
|
2125
|
+
// customer 2.5 million past the allowance owes $12.50, which is what every page
|
|
2126
|
+
// of ours quotes.
|
|
2127
|
+
const BILLING_METER_TOOL_CALLS = 'ganju_custom_tool_calls';
|
|
2128
|
+
|
|
2129
|
+
// The metadata property every meter aggregates (`sum` over it). Ingested events
|
|
2130
|
+
// carry the increment under this key, so it has to agree with the aggregation
|
|
2131
|
+
// configured on all four meters — which is why it lives beside their names
|
|
2132
|
+
// rather than at the call site.
|
|
2133
|
+
const BILLING_METER_UNITS_KEY = 'units';
|
|
2134
|
+
|
|
2135
|
+
// Polar API hosts. Sandbox is a separate host rather than a mode flag, so the
|
|
2136
|
+
// account, products, meters and webhook secret there are all distinct from
|
|
2137
|
+
// production's — which is what `POLAR_SERVER` selects between.
|
|
2138
|
+
const POLAR_API_BASE = 'https://api.polar.sh';
|
|
2139
|
+
const POLAR_SANDBOX_API_BASE = 'https://sandbox-api.polar.sh';
|
|
2140
|
+
const POLAR_SERVER_SANDBOX = 'sandbox' as 'sandbox';
|
|
2141
|
+
const POLAR_SERVER_PRODUCTION = 'production' as 'production';
|
|
2042
2142
|
|
|
2043
2143
|
// Legal documents a user accepts, and the version they're on. Bump the version
|
|
2044
2144
|
// when the document changes materially — existing users are then re-prompted,
|
|
@@ -2047,7 +2147,7 @@ const STRIPE_METER_EMBEDDED = 'ganju_embedded_storage';
|
|
|
2047
2147
|
const CONSENT_DOCUMENT_TERMS = 'terms' as 'terms';
|
|
2048
2148
|
const CONSENT_DOCUMENT_PRIVACY = 'privacy' as 'privacy';
|
|
2049
2149
|
const CONSENT_DOCUMENTS = [CONSENT_DOCUMENT_TERMS, CONSENT_DOCUMENT_PRIVACY];
|
|
2050
|
-
const CONSENT_CURRENT_VERSION = '2026-
|
|
2150
|
+
const CONSENT_CURRENT_VERSION = '2026-09-10';
|
|
2051
2151
|
|
|
2052
2152
|
const CONSENT_SOURCE_SIGNUP = 'signup' as 'signup';
|
|
2053
2153
|
const CONSENT_SOURCE_REACCEPT = 'reaccept' as 'reaccept';
|
|
@@ -2087,6 +2187,40 @@ const CRON_HOURLY = '0 * * * *';
|
|
|
2087
2187
|
const CRON_ERROR_ALERTS = '*/15 * * * *';
|
|
2088
2188
|
|
|
2089
2189
|
const ALERT_STATE_KEY_ERROR_LOG = 'error_log';
|
|
2190
|
+
// Custom-tool usage alerting, keyed per organization as `tool_calls:<id>`. The
|
|
2191
|
+
// monthly hard cap bounds what abuse can spend; these thresholds are what say
|
|
2192
|
+
// something is happening NOW, while there is still a month left to spend.
|
|
2193
|
+
const ALERT_STATE_KEY_TOOL_CALLS = 'tool_calls';
|
|
2194
|
+
// Calls in a single hour. The per-tool rate limit allows 3,600/hour, and a month
|
|
2195
|
+
// of the included million sustained is ~1,370/hour — so five figures in one hour
|
|
2196
|
+
// is either many artifacts at full tilt or something that should be looked at.
|
|
2197
|
+
const ALERT_TOOL_CALL_SURGE = 10_000;
|
|
2198
|
+
// The share of the abuse backstop at which the wall is close enough to warn
|
|
2199
|
+
// about. Half a month's ceiling reached is worth an email even if the rate is
|
|
2200
|
+
// unremarkable, because the alternative is a customer meeting it silently.
|
|
2201
|
+
const ALERT_TOOL_CALL_CAP_FRACTION = 0.5;
|
|
2202
|
+
// How long to stay quiet about one organization after alerting on it. Without
|
|
2203
|
+
// this a busy month is an hourly email, and an hourly email is a muted one.
|
|
2204
|
+
const ALERT_TOOL_CALL_COOLDOWN_HOURS = 6;
|
|
2205
|
+
// Organizations itemised in one digest.
|
|
2206
|
+
const ALERT_TOOL_CALL_MAX_ROWS = 25;
|
|
2207
|
+
|
|
2208
|
+
// The "stop these tools" link the usage digest carries.
|
|
2209
|
+
//
|
|
2210
|
+
// It exists because the alert travels and the response did not: every other
|
|
2211
|
+
// containment step needs a shell holding the production database URL, so an
|
|
2212
|
+
// abuse notice arriving away from a desk was one nobody could act on for hours.
|
|
2213
|
+
//
|
|
2214
|
+
// Narrow on purpose. It disables the organization's custom-code installs — the
|
|
2215
|
+
// tools stop registering at boot, the code, versions and settings all survive,
|
|
2216
|
+
// and any owner can switch them back on. It cannot delete a script, touch a
|
|
2217
|
+
// credential, or reach anything else.
|
|
2218
|
+
const CONTAINMENT_TOKEN_VERSION = '1';
|
|
2219
|
+
const CONTAINMENT_PURPOSE_DISABLE_CUSTOM_CODE = 'disable-custom-code';
|
|
2220
|
+
// Long enough to still work when the mail is read a few hours late, short enough
|
|
2221
|
+
// that a forwarded thread or an archived inbox is not a way in.
|
|
2222
|
+
const CONTAINMENT_TOKEN_TTL_MS = 12 * 60 * 60 * 1000;
|
|
2223
|
+
const CONTAINMENT_PATH = '/containment';
|
|
2090
2224
|
// Only alert on genuine server failures. 4xx rows are expected client errors
|
|
2091
2225
|
// (validation, not-found, quota) and would drown the signal.
|
|
2092
2226
|
const ALERT_MIN_STATUS = 500;
|
|
@@ -2131,6 +2265,9 @@ export const constants = {
|
|
|
2131
2265
|
PRICING_INCLUDED_EMBEDDED_GB,
|
|
2132
2266
|
PRICING_MESSAGE_PER_1K_USD,
|
|
2133
2267
|
PRICING_EMBEDDED_PER_GB_USD,
|
|
2268
|
+
PRICING_INCLUDED_TOOL_CALLS,
|
|
2269
|
+
PRICING_TOOL_CALL_PER_M_USD,
|
|
2270
|
+
PRICING_TOOL_CALL_HARD_CAP,
|
|
2134
2271
|
PRICING_CUSTOM_DOMAIN_USD,
|
|
2135
2272
|
PLAN_FEATURE_ORGANIZATION,
|
|
2136
2273
|
PLAN_FEATURE_PROJECT,
|
|
@@ -2144,10 +2281,17 @@ export const constants = {
|
|
|
2144
2281
|
PLAN_FEATURE_RAW_STORAGE,
|
|
2145
2282
|
PLAN_FEATURE_EMBEDDED_STORAGE,
|
|
2146
2283
|
PLAN_FEATURE_MESSAGE,
|
|
2284
|
+
PLAN_FEATURE_TOOL_CALL,
|
|
2147
2285
|
PLAN_LIMIT_ERROR_CODE,
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2286
|
+
BILLING_METER_MESSAGES,
|
|
2287
|
+
BILLING_METER_SHARED_MESSAGES,
|
|
2288
|
+
BILLING_METER_EMBEDDED,
|
|
2289
|
+
BILLING_METER_TOOL_CALLS,
|
|
2290
|
+
BILLING_METER_UNITS_KEY,
|
|
2291
|
+
POLAR_API_BASE,
|
|
2292
|
+
POLAR_SANDBOX_API_BASE,
|
|
2293
|
+
POLAR_SERVER_SANDBOX,
|
|
2294
|
+
POLAR_SERVER_PRODUCTION,
|
|
2151
2295
|
CONSENT_DOCUMENT_TERMS,
|
|
2152
2296
|
CONSENT_DOCUMENT_PRIVACY,
|
|
2153
2297
|
CONSENT_DOCUMENTS,
|
|
@@ -2161,6 +2305,15 @@ export const constants = {
|
|
|
2161
2305
|
CRON_HOURLY,
|
|
2162
2306
|
CRON_ERROR_ALERTS,
|
|
2163
2307
|
ALERT_STATE_KEY_ERROR_LOG,
|
|
2308
|
+
ALERT_STATE_KEY_TOOL_CALLS,
|
|
2309
|
+
ALERT_TOOL_CALL_SURGE,
|
|
2310
|
+
ALERT_TOOL_CALL_CAP_FRACTION,
|
|
2311
|
+
ALERT_TOOL_CALL_COOLDOWN_HOURS,
|
|
2312
|
+
ALERT_TOOL_CALL_MAX_ROWS,
|
|
2313
|
+
CONTAINMENT_TOKEN_VERSION,
|
|
2314
|
+
CONTAINMENT_PURPOSE_DISABLE_CUSTOM_CODE,
|
|
2315
|
+
CONTAINMENT_TOKEN_TTL_MS,
|
|
2316
|
+
CONTAINMENT_PATH,
|
|
2164
2317
|
ALERT_MIN_STATUS,
|
|
2165
2318
|
ALERT_MAX_ROWS,
|
|
2166
2319
|
ALERT_MAX_GROUPS,
|
|
@@ -2372,6 +2525,9 @@ export const constants = {
|
|
|
2372
2525
|
DEFAULT_LLM_MODEL,
|
|
2373
2526
|
DEFAULT_LLM_SYSTEM_PROMPT,
|
|
2374
2527
|
LLM_CATALOG,
|
|
2528
|
+
LLM_CATALOG_CUSTOM_KEY,
|
|
2529
|
+
LLM_BASE_URL_REQUIRED_MESSAGE,
|
|
2530
|
+
LLM_BASE_URL_INVALID_MESSAGE,
|
|
2375
2531
|
LLM_SYSTEM_DEFAULT,
|
|
2376
2532
|
MAX_TOOL_LOOPS,
|
|
2377
2533
|
CHANNEL_HISTORY_LIMIT,
|
|
@@ -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++)
|
|
77
|
-
|
|
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';
|
|
@@ -129,6 +134,11 @@ import type {
|
|
|
129
134
|
ResolveAttachmentResult
|
|
130
135
|
} from './attachment';
|
|
131
136
|
import { ipv4InPrivateRange, isBlockedHost } from './ssrf';
|
|
137
|
+
import {
|
|
138
|
+
requiresLlmBaseUrl,
|
|
139
|
+
isAllowedLlmBaseUrl,
|
|
140
|
+
llmBaseUrlIssue
|
|
141
|
+
} from './llmBaseUrl';
|
|
132
142
|
import { formatRelative } from './formatRelative';
|
|
133
143
|
import { formatRelativeTime } from './formatRelativeTime';
|
|
134
144
|
import { languageCookieDomain } from './languageCookieDomain';
|
|
@@ -276,6 +286,8 @@ export const utils = {
|
|
|
276
286
|
customCodePreviewScriptName,
|
|
277
287
|
customCodeUploadName,
|
|
278
288
|
customCodePreviewUploadName,
|
|
289
|
+
mintContainmentToken,
|
|
290
|
+
verifyContainmentToken,
|
|
279
291
|
oauthProviders,
|
|
280
292
|
resolveAttachment,
|
|
281
293
|
isExposedResource,
|
|
@@ -290,6 +302,9 @@ export const utils = {
|
|
|
290
302
|
projectPathIssue,
|
|
291
303
|
ipv4InPrivateRange,
|
|
292
304
|
isBlockedHost,
|
|
305
|
+
requiresLlmBaseUrl,
|
|
306
|
+
isAllowedLlmBaseUrl,
|
|
307
|
+
llmBaseUrlIssue,
|
|
293
308
|
validateMessageVariables,
|
|
294
309
|
formatRelative,
|
|
295
310
|
formatRelativeTime,
|
|
@@ -417,6 +432,7 @@ export type {
|
|
|
417
432
|
CustomCodeSendFile,
|
|
418
433
|
CustomCodeCreateResource,
|
|
419
434
|
CustomCodeTokenPayload,
|
|
435
|
+
ContainmentTokenPayload,
|
|
420
436
|
OAuthProviderConfig,
|
|
421
437
|
AttachmentResource,
|
|
422
438
|
ResolvedAttachment,
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { constants } from './constants';
|
|
2
|
+
import { isBlockedHost } from './ssrf';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The rules an `organization_llm.base_url` has to satisfy, in one place.
|
|
6
|
+
*
|
|
7
|
+
* Two write paths need them and neither can see the whole picture alone: the
|
|
8
|
+
* create schema validates a complete payload, while an update is a partial, so
|
|
9
|
+
* the provider may arrive in the request and the base URL may only exist on the
|
|
10
|
+
* stored row. Keeping the rules here means the two can't drift into disagreeing
|
|
11
|
+
* about what a valid row is — the same reason every other write in this package
|
|
12
|
+
* goes through a shared validator rather than an inline check.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Which providers need a base URL of their own.
|
|
17
|
+
*
|
|
18
|
+
* Every other provider has an endpoint baked into its SDK. `openai-compatible`
|
|
19
|
+
* has none: it is the escape hatch for services that merely speak the OpenAI
|
|
20
|
+
* protocol, so without a base URL the adapter would send the customer's key to
|
|
21
|
+
* `api.openai.com` and fail somewhere far from the form they typed it into.
|
|
22
|
+
*/
|
|
23
|
+
export const requiresLlmBaseUrl = (provider?: string | null): boolean =>
|
|
24
|
+
provider === constants.LLM_PROVIDER_OPENAI_COMPATIBLE;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Whether a base URL is safe to send a request to.
|
|
28
|
+
*
|
|
29
|
+
* This is an outbound fetch target chosen by a customer, on a worker holding
|
|
30
|
+
* their decrypted API key, so it gets the same screening as every other
|
|
31
|
+
* caller-supplied URL: `https` only, because the key travels in a header, and
|
|
32
|
+
* no loopback, private or link-local host. The Workers runtime can't resolve
|
|
33
|
+
* DNS, so — as everywhere else `isBlockedHost` is used — this screens literal
|
|
34
|
+
* hosts and IPs and is not a defense against DNS rebinding.
|
|
35
|
+
*/
|
|
36
|
+
export const isAllowedLlmBaseUrl = (value: string): boolean => {
|
|
37
|
+
let parsed: URL;
|
|
38
|
+
try {
|
|
39
|
+
parsed = new URL(value);
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
if (parsed.protocol !== 'https:') return false;
|
|
44
|
+
return !isBlockedHost(parsed.hostname);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Both rules against one provider/base-URL pair: the message to reject with, or
|
|
49
|
+
* `null` when the pair is acceptable. The messages are constants so
|
|
50
|
+
* `localizeZodIssue` can translate them, and both carry a word `matchStatus`
|
|
51
|
+
* recognises — the update path throws this as a plain Error rather than a
|
|
52
|
+
* ZodError, and an unrecognised message there answers 500 where it means 400.
|
|
53
|
+
*/
|
|
54
|
+
export const llmBaseUrlIssue = (
|
|
55
|
+
provider?: string | null,
|
|
56
|
+
baseUrl?: string | null
|
|
57
|
+
): string | null => {
|
|
58
|
+
if (requiresLlmBaseUrl(provider) && !baseUrl) {
|
|
59
|
+
return constants.LLM_BASE_URL_REQUIRED_MESSAGE;
|
|
60
|
+
}
|
|
61
|
+
if (baseUrl && !isAllowedLlmBaseUrl(baseUrl)) {
|
|
62
|
+
return constants.LLM_BASE_URL_INVALID_MESSAGE;
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
};
|
package/src/localizeZodIssue.ts
CHANGED
|
@@ -74,6 +74,12 @@ const CUSTOM_MESSAGES: Record<string, Record<string, string>> = {
|
|
|
74
74
|
},
|
|
75
75
|
[constants.CUSTOM_CODE_RESOURCE_FILE_TOO_LARGE_MESSAGE]: {
|
|
76
76
|
es: `Los bytes del archivo superan el límite de ${constants.CUSTOM_CODE_MAX_RESOURCE_FILE_BYTES / (1024 * 1024)}MB`
|
|
77
|
+
},
|
|
78
|
+
[constants.LLM_BASE_URL_REQUIRED_MESSAGE]: {
|
|
79
|
+
es: 'Se requiere una URL base para un modelo compatible con OpenAI'
|
|
80
|
+
},
|
|
81
|
+
[constants.LLM_BASE_URL_INVALID_MESSAGE]: {
|
|
82
|
+
es: 'La URL base debe ser una dirección https:// pública'
|
|
77
83
|
}
|
|
78
84
|
};
|
|
79
85
|
|
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
|
};
|
package/src/parseHttpError.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
export const parseHttpErrorMessage = async (
|
|
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
|
|