@ganju/utils 0.0.5 → 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.
@@ -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';
@@ -218,6 +223,14 @@ import {
218
223
  isCredentialNeedingReauth
219
224
  } from './oauth';
220
225
  import type { RefreshOAuthTokenInput, RefreshedOAuthToken } from './oauth';
226
+ import {
227
+ isValidTimeZone,
228
+ readCredentialTimeZone,
229
+ credentialTimeZoneIsStale,
230
+ writeCredentialTimeZone,
231
+ fetchGoogleCalendarTimeZone,
232
+ fetchCalcomTimeZone
233
+ } from './vendorTimeZone';
221
234
  import { PlanLimitError, isPlanLimitError } from './planLimitError';
222
235
  import type { PlanLimitDetails } from './planLimitError';
223
236
  import type { PlanLimits } from './constants';
@@ -268,6 +281,8 @@ export const utils = {
268
281
  customCodePreviewScriptName,
269
282
  customCodeUploadName,
270
283
  customCodePreviewUploadName,
284
+ mintContainmentToken,
285
+ verifyContainmentToken,
271
286
  oauthProviders,
272
287
  resolveAttachment,
273
288
  isExposedResource,
@@ -333,6 +348,12 @@ export const utils = {
333
348
  buildReauthMetadata,
334
349
  clearReauthMetadata,
335
350
  isCredentialNeedingReauth,
351
+ isValidTimeZone,
352
+ readCredentialTimeZone,
353
+ credentialTimeZoneIsStale,
354
+ writeCredentialTimeZone,
355
+ fetchGoogleCalendarTimeZone,
356
+ fetchCalcomTimeZone,
336
357
  PlanLimitError,
337
358
  isPlanLimitError
338
359
  };
@@ -403,6 +424,7 @@ export type {
403
424
  CustomCodeSendFile,
404
425
  CustomCodeCreateResource,
405
426
  CustomCodeTokenPayload,
427
+ ContainmentTokenPayload,
406
428
  OAuthProviderConfig,
407
429
  AttachmentResource,
408
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') {
@@ -0,0 +1,171 @@
1
+ // The time zone the user configured with the vendor, and where we keep it.
2
+ //
3
+ // Every scheduling question the model answers is a time-zone question first:
4
+ // "tomorrow at 9" is not an instant until you know whose 9 it is. We had two
5
+ // sources for that and neither worked. `artifact_tool.config.defaultTimeZone`
6
+ // is only written when the owner opens a dropdown and changes it, so in
7
+ // practice it is empty. The fallbacks underneath it were `undefined` (Google
8
+ // then uses the calendar's own zone, which only helps when the timestamp has no
9
+ // offset) and the string 'UTC' (Cal.com, which books the attendee in UTC and
10
+ // tells nobody).
11
+ //
12
+ // But the user already answered this question — in Google Calendar's settings,
13
+ // and in their Cal.com profile. So we ask the vendor instead of asking the
14
+ // owner again, and the answer becomes the default under any explicit choice.
15
+ //
16
+ // It is cached on `artifact_credential.metadata` because that is what it is a
17
+ // property of: the connected account, not the tool row. One artifact can have
18
+ // six calendar tools installed and they all share one connection, so the
19
+ // connection is the only place the answer belongs exactly once.
20
+
21
+ import { constants } from './constants';
22
+
23
+ // Metadata keys on artifact_credential. Namespaced with a prefix that will not
24
+ // collide with the reauth markers written by the OAuth refresh path.
25
+ const TIME_ZONE_KEY = 'timeZone';
26
+ const TIME_ZONE_CHECKED_AT_KEY = 'timeZoneCheckedAt';
27
+
28
+ // How long a cached zone is trusted. A day, because this changes when somebody
29
+ // moves or travels — rarely, and never urgently. The cost of being briefly
30
+ // stale is one meeting in the old zone; the cost of a shorter TTL is a vendor
31
+ // round trip on the path of a tool call.
32
+ const TIME_ZONE_TTL_MS = 24 * 60 * 60 * 1000;
33
+
34
+ /**
35
+ * Is this a time zone the runtime actually knows?
36
+ *
37
+ * Everything downstream — `Intl.DateTimeFormat`, Google's `start.timeZone`,
38
+ * Cal.com's `attendee.timeZone` — throws or 400s on a name it cannot resolve.
39
+ * A vendor returning something unexpected must degrade to "we don't know"
40
+ * rather than poison every later call with a value that cannot be used.
41
+ */
42
+ export const isValidTimeZone = (value: unknown): value is string => {
43
+ if (typeof value !== 'string' || !value.trim()) return false;
44
+ try {
45
+ new Intl.DateTimeFormat('en-US', { timeZone: value.trim() });
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ };
51
+
52
+ /** The cached zone on a credential's metadata, or null. */
53
+ export const readCredentialTimeZone = (metadata: unknown): string | null => {
54
+ if (!metadata || typeof metadata !== 'object') return null;
55
+ const value = (metadata as Record<string, unknown>)[TIME_ZONE_KEY];
56
+ return isValidTimeZone(value) ? value.trim() : null;
57
+ };
58
+
59
+ /**
60
+ * Should we ask the vendor again?
61
+ *
62
+ * True when there is nothing cached, when the stamp is missing or unreadable,
63
+ * or when the TTL has passed. A cache with no stamp is treated as stale rather
64
+ * than as fresh-forever — the conservative direction, since the only cost is
65
+ * one request.
66
+ */
67
+ export const credentialTimeZoneIsStale = (
68
+ metadata: unknown,
69
+ now: number = Date.now()
70
+ ): boolean => {
71
+ if (!readCredentialTimeZone(metadata)) return true;
72
+ const raw = (metadata as Record<string, unknown>)[TIME_ZONE_CHECKED_AT_KEY];
73
+ if (typeof raw !== 'string') return true;
74
+ const checkedAt = Date.parse(raw);
75
+ if (!Number.isFinite(checkedAt)) return true;
76
+ return now - checkedAt >= TIME_ZONE_TTL_MS;
77
+ };
78
+
79
+ /**
80
+ * Merge a freshly read zone into a credential's metadata.
81
+ *
82
+ * Merges rather than replaces, because this column also carries the reauth
83
+ * markers — writing a bare `{ timeZone }` here would clear `needsReauth` and
84
+ * silently re-enable a connection the refresh path had flagged as broken.
85
+ *
86
+ * A null zone (the vendor could not tell us) still stamps the check, so a
87
+ * provider that never reports one is asked once a day rather than on every
88
+ * single call.
89
+ */
90
+ export const writeCredentialTimeZone = (
91
+ previous: unknown,
92
+ timeZone: string | null,
93
+ now: Date = new Date()
94
+ ): Record<string, unknown> => {
95
+ const base =
96
+ previous && typeof previous === 'object'
97
+ ? { ...(previous as Record<string, unknown>) }
98
+ : {};
99
+ if (isValidTimeZone(timeZone)) {
100
+ base[TIME_ZONE_KEY] = timeZone.trim();
101
+ } else {
102
+ delete base[TIME_ZONE_KEY];
103
+ }
104
+ base[TIME_ZONE_CHECKED_AT_KEY] = now.toISOString();
105
+ return base;
106
+ };
107
+
108
+ /**
109
+ * The zone Google Calendar is configured in, from the primary calendar.
110
+ *
111
+ * Deliberately NOT `GET /users/me/settings/timezone`, which is the more
112
+ * direct answer to "what did the user configure" and needs
113
+ * `calendar.settings.readonly` — a scope we do not request and could not add
114
+ * without sending every already-connected user back through consent. The
115
+ * primary calendar's zone is the same value in every case that matters, and
116
+ * `calendar.readonly` already covers it.
117
+ *
118
+ * Returns null on any failure. Not knowing the zone is a state the callers
119
+ * handle; a throw here would take a tool call or a chat turn with it.
120
+ */
121
+ export const fetchGoogleCalendarTimeZone = async (
122
+ accessToken: string
123
+ ): Promise<string | null> => {
124
+ try {
125
+ const response = await fetch(
126
+ `${constants.GOOGLE_CALENDAR_API_BASE}/calendars/primary`,
127
+ {
128
+ headers: {
129
+ Authorization: `Bearer ${accessToken}`,
130
+ Accept: 'application/json'
131
+ }
132
+ }
133
+ );
134
+ if (!response.ok) return null;
135
+ const payload = (await response.json()) as { timeZone?: unknown };
136
+ return isValidTimeZone(payload?.timeZone) ? payload.timeZone.trim() : null;
137
+ } catch {
138
+ return null;
139
+ }
140
+ };
141
+
142
+ /**
143
+ * The zone on the connected Cal.com profile.
144
+ *
145
+ * This is the host's zone — the one their availability is written in — which
146
+ * is what "9am" means when the artifact owner or their bot says it. It is not
147
+ * the attendee's zone; see the booking handler for why we use it there anyway.
148
+ *
149
+ * Same null-on-failure contract as the Google reader above.
150
+ */
151
+ export const fetchCalcomTimeZone = async (
152
+ apiKey: string
153
+ ): Promise<string | null> => {
154
+ try {
155
+ const response = await fetch(`${constants.CALCOM_API_BASE}/me`, {
156
+ headers: {
157
+ Authorization: `Bearer ${apiKey}`,
158
+ 'cal-api-version': constants.CALCOM_API_VERSION_ME,
159
+ Accept: 'application/json'
160
+ }
161
+ });
162
+ if (!response.ok) return null;
163
+ const payload = (await response.json()) as {
164
+ data?: { timeZone?: unknown };
165
+ };
166
+ const value = payload?.data?.timeZone;
167
+ return isValidTimeZone(value) ? value.trim() : null;
168
+ } catch {
169
+ return null;
170
+ }
171
+ };