@assembly-js/app-foundation 0.0.22 → 0.0.24

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.
@@ -5,7 +5,7 @@
5
5
  // never be dragged into the Edge middleware bundle that imports ./client
6
6
  // (proxy.ts pulls constants from that barrel).
7
7
  import { AssemblyBridge } from '@assembly-js/app-bridge';
8
- import { APP_CSRF_HEADER, TOKEN_EXPIRED_HEADER } from './session-exchange.js';
8
+ import { APP_CSRF_HEADER, logRenewOutcome, TOKEN_EXPIRED_HEADER, } from './session-exchange.js';
9
9
  const REEXCHANGE_PATH = '/api/assembly-reexchange';
10
10
  // Single-flight gate for renewals. Session tokens are single-use for exchange:
11
11
  // when the 60-minute bearer expires, every in-flight fetch on the page 401s at
@@ -33,23 +33,32 @@ async function renewBearer() {
33
33
  let token = AssemblyBridge.sessionToken.getCurrent()?.token ?? '';
34
34
  // Proactively refresh: we're recovering from an expiry, so avoid re-minting
35
35
  // from a session token that may itself be stale.
36
+ let bridgeRefreshFailed = false;
36
37
  try {
37
38
  const refreshed = await AssemblyBridge.sessionToken.refresh();
38
39
  token = refreshed?.token ?? token;
39
40
  }
40
41
  catch {
41
42
  // Ignore: fall back to the current token below.
43
+ bridgeRefreshFailed = true;
42
44
  }
43
- if (!token)
45
+ if (!token) {
46
+ logRenewOutcome('no_token', { bridgeRefreshFailed });
44
47
  return false;
48
+ }
45
49
  try {
46
50
  const res = await fetch(REEXCHANGE_PATH, {
47
51
  method: 'POST',
48
52
  headers: { 'x-assembly-token': token, [APP_CSRF_HEADER]: '1' },
49
53
  });
54
+ logRenewOutcome(res.ok ? 'renewed' : 'rejected', {
55
+ bridgeRefreshFailed,
56
+ status: res.status,
57
+ });
50
58
  return res.ok;
51
59
  }
52
60
  catch {
61
+ logRenewOutcome('unavailable', { bridgeRefreshFailed });
53
62
  return false;
54
63
  }
55
64
  }
package/dist/nango.d.ts CHANGED
@@ -217,3 +217,5 @@ export declare function slackPostMessage(slack: IntegrationClient, args: {
217
217
  channel: string;
218
218
  text: string;
219
219
  }): Promise<SlackPostMessageResult>;
220
+ /** Slack history returns mention tokens, not names. Resolve them before render. */
221
+ export declare function slackResolveMentions(slack: IntegrationClient, text: string): Promise<string>;
package/dist/nango.js CHANGED
@@ -496,3 +496,119 @@ export async function slackPostMessage(slack, args) {
496
496
  }
497
497
  return { ok: result.ok, ts: result.ts, channelId, error: result.error };
498
498
  }
499
+ // Memoize successful mention lookups so a history page does not re-hit Slack
500
+ // for the same ID. Misses stay uncached so a later reconnect can succeed.
501
+ const SLACK_MENTION_RE = /<@([UW][A-Z0-9]+)(?:\|([^>]+))?>|<#([CG][A-Z0-9]+)(?:\|([^>]+))?>|<!subteam\^(S[A-Z0-9]+)(?:\|([^>]+))?>/g;
502
+ const slackMentionNameCache = new Map();
503
+ const SLACK_MENTION_FALLBACK = {
504
+ user: 'a user',
505
+ channel: 'a channel',
506
+ subteam: 'a user group',
507
+ };
508
+ function slackMentionRe() {
509
+ return new RegExp(SLACK_MENTION_RE.source, 'g');
510
+ }
511
+ function withMentionSigil(label, sigil) {
512
+ return label.startsWith(sigil) ? label : `${sigil}${label}`;
513
+ }
514
+ async function slackLookupJson(slack, endpoint, params) {
515
+ try {
516
+ const res = await slack.get({ endpoint, params });
517
+ const data = (await res.json());
518
+ if (!data.ok)
519
+ return null;
520
+ return data;
521
+ }
522
+ catch {
523
+ // Lookup is best-effort: a missing scope or a vanished user must not
524
+ // fail the whole history render.
525
+ return null;
526
+ }
527
+ }
528
+ function slackUserDisplayName(user) {
529
+ const display = user.profile?.display_name?.trim();
530
+ if (display)
531
+ return display;
532
+ const real = user.profile?.real_name?.trim() || user.real_name?.trim();
533
+ if (real)
534
+ return real;
535
+ return user.name?.trim() || undefined;
536
+ }
537
+ async function slackResolveUserName(slack, id) {
538
+ const cacheKey = `user:${id}`;
539
+ const cached = slackMentionNameCache.get(cacheKey);
540
+ if (cached)
541
+ return cached;
542
+ const data = await slackLookupJson(slack, '/users.info', { user: id });
543
+ const user = data?.user;
544
+ const name = user ? slackUserDisplayName(user) : undefined;
545
+ if (!name)
546
+ return SLACK_MENTION_FALLBACK.user;
547
+ const label = withMentionSigil(name, '@');
548
+ slackMentionNameCache.set(cacheKey, label);
549
+ return label;
550
+ }
551
+ async function slackResolveChannelName(slack, id) {
552
+ const cacheKey = `channel:${id}`;
553
+ const cached = slackMentionNameCache.get(cacheKey);
554
+ if (cached)
555
+ return cached;
556
+ const data = await slackLookupJson(slack, '/conversations.info', { channel: id });
557
+ const channel = data?.channel;
558
+ const name = channel?.name?.trim();
559
+ if (!name)
560
+ return SLACK_MENTION_FALLBACK.channel;
561
+ const label = withMentionSigil(name, '#');
562
+ slackMentionNameCache.set(cacheKey, label);
563
+ return label;
564
+ }
565
+ async function slackResolveSubteamName(slack, id) {
566
+ const cacheKey = `subteam:${id}`;
567
+ const cached = slackMentionNameCache.get(cacheKey);
568
+ if (cached)
569
+ return cached;
570
+ const data = await slackLookupJson(slack, '/usergroups.info', { usergroup: id });
571
+ const group = data?.usergroup;
572
+ const name = group?.handle?.trim() || group?.name?.trim();
573
+ if (!name)
574
+ return SLACK_MENTION_FALLBACK.subteam;
575
+ const label = withMentionSigil(name, '@');
576
+ slackMentionNameCache.set(cacheKey, label);
577
+ return label;
578
+ }
579
+ /** Slack history returns mention tokens, not names. Resolve them before render. */
580
+ export async function slackResolveMentions(slack, text) {
581
+ if (!text)
582
+ return text;
583
+ const replacements = new Map();
584
+ const lookups = new Map();
585
+ for (const match of text.matchAll(slackMentionRe())) {
586
+ const [raw, userId, userLabel, channelId, channelLabel, subteamId, subteamLabel] = match;
587
+ if (replacements.has(raw) || lookups.has(raw))
588
+ continue;
589
+ if (userLabel) {
590
+ replacements.set(raw, withMentionSigil(userLabel, '@'));
591
+ }
592
+ else if (channelLabel) {
593
+ replacements.set(raw, withMentionSigil(channelLabel, '#'));
594
+ }
595
+ else if (subteamLabel) {
596
+ replacements.set(raw, withMentionSigil(subteamLabel, '@'));
597
+ }
598
+ else if (userId) {
599
+ lookups.set(raw, slackResolveUserName(slack, userId));
600
+ }
601
+ else if (channelId) {
602
+ lookups.set(raw, slackResolveChannelName(slack, channelId));
603
+ }
604
+ else if (subteamId) {
605
+ lookups.set(raw, slackResolveSubteamName(slack, subteamId));
606
+ }
607
+ }
608
+ await Promise.all([...lookups.entries()].map(async ([raw, pending]) => {
609
+ replacements.set(raw, await pending);
610
+ }));
611
+ if (replacements.size === 0)
612
+ return text;
613
+ return text.replace(slackMentionRe(), (raw) => replacements.get(raw) ?? raw);
614
+ }
@@ -15,6 +15,12 @@ export type CookieOutcome = 'served_from_cookie' | 'minted' | `fail_closed:${str
15
15
  export declare function logCookieOutcome(outcome: CookieOutcome, detail?: {
16
16
  rejectedToken?: string;
17
17
  }): void;
18
+ export declare const RENEW_OUTCOME_EVENT = "assembly.renew_outcome";
19
+ export type RenewOutcome = 'renewed' | 'no_token' | 'rejected' | 'unavailable';
20
+ export declare function logRenewOutcome(outcome: RenewOutcome, detail?: {
21
+ bridgeRefreshFailed?: boolean;
22
+ status?: number;
23
+ }): void;
18
24
  export declare function isDocumentRequest(request: {
19
25
  method: string;
20
26
  headers: {
@@ -88,6 +88,25 @@ detail) {
88
88
  ...(detail?.rejectedToken ? { rejected_token: detail.rejectedToken } : {}),
89
89
  }));
90
90
  }
91
+ // One structured line per bearer renewal attempt. The renewal path is the one
92
+ // an idle session takes on its way back: the 60-minute bearer has expired and
93
+ // everything now depends on the parent still holding an exchangeable session
94
+ // token. Today every way that can fail collapses into the same `false`, so a
95
+ // dead parent bridge, a refused token and a network blip are indistinguishable
96
+ // after the fact. Outcomes are a closed set, mirroring COOKIE_OUTCOME_EVENT.
97
+ export const RENEW_OUTCOME_EVENT = 'assembly.renew_outcome';
98
+ export function logRenewOutcome(outcome,
99
+ // `bridgeRefreshFailed` records that the pre-renewal token refresh threw and
100
+ // we fell back to the token already in hand — on its own that is survivable,
101
+ // but paired with a rejection it names the parent as the failing side.
102
+ detail) {
103
+ console.log(JSON.stringify({
104
+ event_type: RENEW_OUTCOME_EVENT,
105
+ outcome,
106
+ ...(detail?.bridgeRefreshFailed ? { bridge_refresh_failed: true } : {}),
107
+ ...(typeof detail?.status === 'number' ? { status: detail.status } : {}),
108
+ }));
109
+ }
91
110
  // Two-tier detection: Sec-Fetch-Dest when the browser sends it (all evergreen
92
111
  // browsers; 'iframe' is what an embedded app's navigations carry), Accept
93
112
  // text/html otherwise. Deliberately NOT matching `*/*`: browser fetch() GETs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.0.22",
3
+ "version": "0.0.24",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -2,12 +2,15 @@
2
2
 
3
3
  Everything except `send-message` needs membership, not just visibility. Posting reaches any public channel without joining (`chat:write.public`); reading history, adding reactions, and inviting users all fail `not_in_channel` until the bot joins.
4
4
 
5
+ - **Warn before the wall.** When the app reads from a channel — history, reactions, invites, or scheduled messages — tell the user at build time that the bot needs to be invited to that channel, and render a visible "Invite @Assembly to this channel" hint next to any channel picker.
6
+ - **Check first, don't wait for the failure.** Call `get-channel-info` and read `is_member` before the first read/react/invite/schedule call on a channel, instead of waiting for the `not_in_channel` failure. Keep the reactive join-and-retry below as the fallback for when the pre-flight check was skipped or state changed since.
5
7
  - **Post with `slackPostMessage` from `@assembly-js/app-foundation/server` — never hand-roll `chat.postMessage`.** It resolves a `#name` to the channel ID, posts, and on `not_in_channel` joins the channel and retries once. Slack-level failures come back as `{ ok: false, error }`; on `not_in_channel` render "invite the bot to #channel in Slack" (private channels cannot be self-joined).
6
8
  - **List channels with `slackListChannels` from `@assembly-js/app-foundation/server` — never page the `list-channels` action yourself.** That action returns at most 200 per page, so a large workspace is dozens of sequential calls and a 20–30 second dropdown; the helper walks at Slack's maximum page size and handles rate limits and the private-channel scope fallback. It returns `{ id, name, isPrivate }` sorted by name.
7
9
  - **Resolve channel names with `slackFindChannelId` from `@assembly-js/app-foundation/server` for every other per-channel call.** It handles cursor pagination (a single `conversations.list` misses channels in large workspaces), name normalization, scope fallback, rate limits, and caching. Use the returned ID; most Slack methods reject a `#name` with `invalid_arguments`.
8
10
  - **Read the Slack error code with `slackErrorCode(err)` from `@assembly-js/app-foundation/server` — never read `err.detail` yourself.** The vendored scripts put the code in different places inside `detail`; the helper returns it (`'not_in_channel'`, `'channel_not_found'`, `'is_archived'`, …) or `undefined` when the error isn't a Slack rejection.
11
+ - **Resolve mention IDs with `slackResolveMentions` from `@assembly-js/app-foundation/server` before rendering Slack message text.** History returns raw `<@U…>`, `<#C…|name>`, and `<!subteam^ID>` tokens. The helper replaces them with display names and falls back to "a user" / "a channel" / "a user group" when a lookup fails or `usergroups:read` is missing. Leave `<!here>` / `<!channel>` / `<!everyone>` as-is.
9
12
  - **Every other per-channel call (history, reactions, invites):** when `slackErrorCode(err) === 'not_in_channel'`, call `join-channel` with the channel ID, then retry once. Private channels fail the join with `method_not_supported_for_channel_type` — tell the user to invite the bot instead of retrying.
10
- - **Scopes:** `chat:write.public` lets posting reach any public channel without joining, so only the other calls need `join-channel` (which needs `channels:join`). Add `groups:read` if the app must find private channels.
13
+ - **Scopes:** `chat:write.public` lets posting reach any public channel without joining, so only the other calls need `join-channel` (which needs `channels:join`). Add `groups:read` if the app must find private channels. `usergroups:read` is what lets `slackResolveMentions` name a `<!subteam^…>` token; without it the helper falls back to "a user group".
11
14
 
12
15
  ```typescript
13
16
  import {
@@ -16,12 +19,14 @@ import {
16
19
  slackFindChannelId,
17
20
  slackListChannels,
18
21
  slackPostMessage,
22
+ slackResolveMentions,
19
23
  } from '@assembly-js/app-foundation/server';
20
24
 
21
25
  const slack = await getIntegration('slack');
22
26
  const channels = await slackListChannels(slack); // dropdown options
23
27
  const channelId = await slackFindChannelId(slack, 'general');
24
28
  const posted = await slackPostMessage(slack, { channel: '#general', text: 'Hello' });
29
+ const readable = await slackResolveMentions(slack, rawMessageText);
25
30
  ```
26
31
 
27
32
  ```typescript