@segmentstream-cloud/app 0.17.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,19 @@
1
1
  import { cookies, headers } from "next/headers";
2
2
  import { redirect } from "next/navigation";
3
3
  import { NextResponse } from "next/server";
4
+ import { hasChannelApps } from "./config.js";
4
5
  import { cookiePolicyFor, isLoopbackHostname, safeReturnTo } from "./cookies.js";
5
- import { isSameOrigin } from "./decision-pages.js";
6
+ import { channelAppFraming } from "./telegram.js";
7
+ import { createChannelSession } from "./channel-session.js";
8
+ import { isSameOrigin, withUnframeablePages } from "./decision-pages.js";
9
+ import { apiErrorFrom, SegmentStreamApiError } from "./errors.js";
6
10
  import { parseMe } from "./me.js";
7
11
  import { createOntologyTransport, ontologyOrigin } from "./ontology-transport.js";
8
12
  import { createOntologyClient } from "./ontology.js";
9
13
  import { createAgentAuthorizations } from "./oauth.js";
10
14
  import { createAgentIdentities } from "./agent.js";
11
15
  import { isAllowed } from "./permissions.js";
16
+ import { matchesPathPrefix, normalizePublicPath } from "./proxy.js";
12
17
  import { base64UrlDecode, base64UrlEncode, constantTimeEqual, createCodeChallenge, randomToken, } from "./pkce.js";
13
18
  const PREFIX = "/api/segmentstream";
14
19
  const CALLBACK = `${PREFIX}/callback`;
@@ -34,6 +39,12 @@ export function withFederation(base, config, oidc, options) {
34
39
  oidc.issuer.endsWith("/")) {
35
40
  throw new Error("SEGMENTSTREAM_OIDC_ISSUER must be an HTTPS issuer without a trailing slash.");
36
41
  }
42
+ const channel = createChannelSession({
43
+ enabled: hasChannelApps(config.channels),
44
+ workspaceId: config.workspaceId,
45
+ ontologyUrl: options.ontologyUrl,
46
+ fetch: options.fetch,
47
+ });
37
48
  const token = async () => {
38
49
  const cookieStore = await cookies();
39
50
  const host = (await headers()).get("host") ?? "";
@@ -50,32 +61,71 @@ export function withFederation(base, config, oidc, options) {
50
61
  ontologyUrl: options.ontologyUrl,
51
62
  credentials: { token },
52
63
  });
64
+ const fallbackTransport = options.proofTransport ?? options.channelSession?.transport ?? transport;
53
65
  const ontologyTransport = {
54
- fetch: async (path, init = {}) => (await token())
55
- ? transport.fetch(path, init)
56
- : options.proofTransport
57
- ? options.proofTransport.fetch(path, init)
58
- : transport.fetch(path, init),
66
+ fetch: async (path, init = {}) => (await channel.read())
67
+ ? channel.transport.fetch(path, init)
68
+ : (await token())
69
+ ? transport.fetch(path, init)
70
+ : fallbackTransport.fetch(path, init),
59
71
  };
60
72
  async function getUser() {
61
- if (!(await token())) {
62
- return options.neon ? base.getUser() : null;
63
- }
64
- const response = await transport.fetch("/me", { cache: "no-store" });
65
- const user = response.ok ? parseMe(await response.json()) : null;
66
- return user
67
- ? {
68
- id: user.id,
69
- ...(user.email ? { email: user.email } : {}),
70
- ...(user.name ? { name: user.name } : {}),
73
+ const channelUser = (await channel.read())?.user;
74
+ if (channelUser) {
75
+ return {
76
+ ...channelUser,
77
+ email: channelUser.email ?? undefined,
78
+ name: channelUser.name ?? undefined,
71
79
  organizationId: config.organizationId,
72
80
  workspaceId: config.workspaceId,
73
- roles: user.roles,
74
- permissions: user.permissions,
75
- kind: user.kind,
76
- active: user.active,
77
- }
78
- : null;
81
+ };
82
+ }
83
+ if (!(await token())) {
84
+ return options.neon ? base.getUser() : (options.channelSession?.getUser() ?? null);
85
+ }
86
+ return oidcUser();
87
+ }
88
+ async function oidcUser() {
89
+ return userFromResponse(await transport.fetch("/me", { cache: "no-store" }));
90
+ }
91
+ async function userFromResponse(response) {
92
+ if (response.status === 401 || response.status === 403) {
93
+ return null;
94
+ }
95
+ let payload;
96
+ try {
97
+ payload = await response.json();
98
+ }
99
+ catch (cause) {
100
+ throw new SegmentStreamApiError({
101
+ code: "ontology_invalid_response",
102
+ status: 502,
103
+ detail: "ontology_invalid_response",
104
+ cause,
105
+ });
106
+ }
107
+ if (!response.ok) {
108
+ throw apiErrorFrom(response.status, payload, "session_unavailable");
109
+ }
110
+ const user = parseMe(payload);
111
+ if (!user) {
112
+ throw new SegmentStreamApiError({
113
+ code: "ontology_invalid_response",
114
+ status: 502,
115
+ detail: "ontology_invalid_response",
116
+ });
117
+ }
118
+ return {
119
+ id: user.id,
120
+ ...(user.email ? { email: user.email } : {}),
121
+ ...(user.name ? { name: user.name } : {}),
122
+ organizationId: config.organizationId,
123
+ workspaceId: config.workspaceId,
124
+ roles: user.roles,
125
+ permissions: user.permissions,
126
+ kind: user.kind,
127
+ active: user.active,
128
+ };
79
129
  }
80
130
  const setSession = (response, request, value) => {
81
131
  const { secure } = cookiePolicyFor(new URL(request.url), config.workspaceId);
@@ -87,7 +137,24 @@ export function withFederation(base, config, oidc, options) {
87
137
  const pendingName = cookieName(config.workspaceId, secure, "pending");
88
138
  const path = request.nextUrl.pathname;
89
139
  if (path === `${PREFIX}/configuration`) {
90
- return NextResponse.json({ enabled: true }, { headers: { "cache-control": "no-store" } });
140
+ return NextResponse.json({ enabled: true, neon: options.neon, channels: config.channels ?? {} }, { headers: { "cache-control": "no-store" } });
141
+ }
142
+ if (path === `${PREFIX}/session`) {
143
+ const credential = await token();
144
+ if (!credential) {
145
+ return NextResponse.json(null, { status: 401, headers: { "cache-control": "no-store" } });
146
+ }
147
+ const user = await oidcUser();
148
+ return NextResponse.json(user
149
+ ? {
150
+ session: { id: await createCodeChallenge(credential) },
151
+ user,
152
+ source: "oidc",
153
+ }
154
+ : { error: "unauthorized", source: "oidc" }, {
155
+ status: user ? 200 : 401,
156
+ headers: { "cache-control": "no-store" },
157
+ });
91
158
  }
92
159
  if (path === `${PREFIX}/sign-in`) {
93
160
  const state = randomToken();
@@ -261,6 +328,7 @@ export function withFederation(base, config, oidc, options) {
261
328
  oauth: createAgentAuthorizations(ontologyTransport),
262
329
  agent: createAgentIdentities(ontologyTransport),
263
330
  signOut: async (settings) => {
331
+ await options.channelSession?.signOut();
264
332
  if (await token()) {
265
333
  await transport.fetch("/federation/logout", { method: "POST" }).catch(() => undefined);
266
334
  }
@@ -273,22 +341,28 @@ export function withFederation(base, config, oidc, options) {
273
341
  }
274
342
  redirect(safeReturnTo(settings?.redirectTo ?? "/sign-in"));
275
343
  },
276
- proxy: (settings) => async (request) => {
344
+ proxy: (settings) => withUnframeablePages(async (request) => {
345
+ const forwardedOrPublic = [
346
+ PREFIX,
347
+ "/sign-in",
348
+ "/api/auth",
349
+ "/mcp",
350
+ "/api/ontology",
351
+ "/_next",
352
+ "/.well-known",
353
+ ...(settings?.publicPaths ?? []).map(normalizePublicPath),
354
+ ].some((path) => matchesPathPrefix(request.nextUrl.pathname, path));
355
+ const channelResponse = forwardedOrPublic ? undefined : await channel.proxy(request);
356
+ if (channelResponse) {
357
+ return channelResponse;
358
+ }
277
359
  const { secure } = cookiePolicyFor(new URL(request.url), config.workspaceId);
278
360
  const credential = request.cookies.get(cookieName(config.workspaceId, secure))?.value;
279
- if (request.nextUrl.pathname.startsWith(PREFIX)) {
361
+ if (matchesPathPrefix(request.nextUrl.pathname, PREFIX)) {
280
362
  return NextResponse.next();
281
363
  }
282
- if (credential &&
283
- ![
284
- "/sign-in",
285
- "/api/auth",
286
- "/mcp",
287
- "/api/ontology",
288
- "/_next",
289
- ...(settings?.publicPaths ?? []),
290
- ].some((path) => request.nextUrl.pathname === path || request.nextUrl.pathname.startsWith(`${path}/`))) {
291
- const origin = options.ontologyUrl ?? request.nextUrl.origin;
364
+ if (credential && !forwardedOrPublic) {
365
+ const origin = await ontologyOrigin(options.ontologyUrl ?? request.nextUrl.origin);
292
366
  const response = await options.fetch(`${origin}/api/ontology/me`, {
293
367
  headers: { authorization: `Bearer ${credential}` },
294
368
  cache: "no-store",
@@ -296,14 +370,18 @@ export function withFederation(base, config, oidc, options) {
296
370
  signal: AbortSignal.timeout(10000),
297
371
  });
298
372
  if (response.ok) {
373
+ await userFromResponse(response);
299
374
  return NextResponse.next();
300
375
  }
376
+ if (response.status !== 401 && response.status !== 403) {
377
+ return NextResponse.json({ error: "session_unavailable" }, { status: 503, headers: { "cache-control": "no-store", "retry-after": "5" } });
378
+ }
301
379
  const denied = NextResponse.redirect(new URL("/sign-in", request.url));
302
380
  denied.cookies.set(cookieName(config.workspaceId, secure), "", attributes(secure, 0));
303
381
  return denied;
304
382
  }
305
383
  return base.proxy(settings)(request);
306
- },
384
+ }, channelAppFraming(config.channels)),
307
385
  };
308
386
  }
309
387
  function protectResponse(response) {
@@ -5,4 +5,6 @@ export interface SegmentStreamRouteHandlers {
5
5
  readonly GET: RouteHandler;
6
6
  readonly POST: RouteHandler;
7
7
  }
8
- export declare function createHandlers(context: AppContext): SegmentStreamRouteHandlers;
8
+ export declare function createHandlers(context: AppContext, { neon }?: {
9
+ readonly neon?: boolean;
10
+ }): SegmentStreamRouteHandlers;
package/dist/handlers.js CHANGED
@@ -3,9 +3,13 @@ import { authFlowCookieName, clearCookie, clearSessionCookies, cookiePolicyFor,
3
3
  import { isSameOrigin } from "./decision-pages.js";
4
4
  import { constantTimeEqual } from "./pkce.js";
5
5
  import { CALLBACK_PATH, SIGNED_OUT_PATH, SIGN_IN_PATH, SDK_ROUTE_PREFIX, signInRedirect, } from "./sign-in.js";
6
- export function createHandlers(context) {
6
+ export function createHandlers(context, { neon = false } = {}) {
7
7
  async function GET(request) {
8
8
  switch (request.nextUrl.pathname) {
9
+ case `${SDK_ROUTE_PREFIX}/configuration`:
10
+ return NextResponse.json({ enabled: false, neon, channels: context.config.channels ?? {} }, {
11
+ headers: { "cache-control": "no-store" },
12
+ });
9
13
  case CALLBACK_PATH:
10
14
  return handleCallback(context, request);
11
15
  case SIGN_IN_PATH:
package/dist/neon.js CHANGED
@@ -3,7 +3,9 @@ import { decodeJwt } from "jose";
3
3
  import { cookies } from "next/headers";
4
4
  import { redirect } from "next/navigation";
5
5
  import { NextResponse } from "next/server";
6
+ import { hasChannelApps } from "./config.js";
6
7
  import { replaceRequestCookies, safeReturnTo } from "./cookies.js";
8
+ import { createChannelSession } from "./channel-session.js";
7
9
  import { NOT_INVITED, SIGN_IN_LINK_INVALID, SegmentStreamApiError, apiErrorFrom, readJson, } from "./errors.js";
8
10
  import { parseMe } from "./me.js";
9
11
  import { createOntologyTransport } from "./ontology-transport.js";
@@ -23,6 +25,7 @@ const ONTOLOGY_PATHS = ["/api/ontology", "/mcp"];
23
25
  export const OPEN_PATHS = [
24
26
  AUTH_API_PATH,
25
27
  SIGN_IN_PAGE,
28
+ "/api/segmentstream/configuration",
26
29
  "/api/health",
27
30
  "/_next",
28
31
  "/favicon.ico",
@@ -79,6 +82,12 @@ class ExpiringCache {
79
82
  */
80
83
  export function createNeonSession(dependencies) {
81
84
  const { config, settings, now } = dependencies;
85
+ const channel = createChannelSession({
86
+ enabled: hasChannelApps(config.channels),
87
+ workspaceId: config.workspaceId,
88
+ ontologyUrl: settings.ontologyUrl,
89
+ fetch: dependencies.fetch,
90
+ });
82
91
  const tokens = new ExpiringCache(now);
83
92
  const users = new ExpiringCache(now);
84
93
  const loadingUsers = new Map();
@@ -107,17 +116,25 @@ export function createNeonSession(dependencies) {
107
116
  // before any configuration is needed. Thrown errors pass through untouched: Next.js signals
108
117
  // dynamic rendering with them.
109
118
  await cookies();
119
+ const channelSession = await channelIdentity();
120
+ if (channelSession) {
121
+ return channelSession;
122
+ }
110
123
  const result = await neonAuth().getSession();
111
124
  if (result.error !== null) {
112
125
  if (result.error.status === 401) {
113
- return null;
126
+ return channelIdentity();
127
+ }
128
+ const fallback = await channelIdentity();
129
+ if (fallback) {
130
+ return fallback;
114
131
  }
115
132
  throw authUnavailable(result.error);
116
133
  }
117
134
  const session = result.data?.session;
118
135
  const user = result.data?.user;
119
136
  if (session === null || session === undefined || user === null || user === undefined) {
120
- return null;
137
+ return channelIdentity();
121
138
  }
122
139
  return {
123
140
  sessionId: session.id,
@@ -126,8 +143,23 @@ export function createNeonSession(dependencies) {
126
143
  name: user.name ?? undefined,
127
144
  };
128
145
  }
146
+ async function channelIdentity() {
147
+ const session = await channel.read();
148
+ return session
149
+ ? {
150
+ sessionId: session.user.id,
151
+ userId: session.user.id,
152
+ name: session.user.name ?? undefined,
153
+ email: session.user.email ?? undefined,
154
+ channel: session,
155
+ }
156
+ : null;
157
+ }
129
158
  /** The session's JWT for the ontology service, reused until shortly before it expires. */
130
159
  async function sessionToken(session) {
160
+ if (session.channel) {
161
+ return session.channel.token;
162
+ }
131
163
  const cached = tokens.get(session.sessionId);
132
164
  if (cached !== undefined) {
133
165
  return cached;
@@ -135,7 +167,7 @@ export function createNeonSession(dependencies) {
135
167
  const result = await neonAuth().token();
136
168
  if (result.error !== null) {
137
169
  if (result.error.status === 401) {
138
- return null;
170
+ return channel.token();
139
171
  }
140
172
  throw authUnavailable(result.error);
141
173
  }
@@ -171,6 +203,9 @@ export function createNeonSession(dependencies) {
171
203
  },
172
204
  };
173
205
  async function currentUser(session) {
206
+ if (session.channel) {
207
+ return session.channel.user;
208
+ }
174
209
  const cached = users.get(session.sessionId);
175
210
  if (cached !== undefined) {
176
211
  return cached;
@@ -262,6 +297,7 @@ export function createNeonSession(dependencies) {
262
297
  return user !== null && isAllowed(permission, user);
263
298
  }
264
299
  async function signOut(options = {}) {
300
+ await channel.signOut();
265
301
  const session = await identity().catch(() => null);
266
302
  if (session !== null) {
267
303
  tokens.delete(session.sessionId);
@@ -317,6 +353,10 @@ export function createNeonSession(dependencies) {
317
353
  if (publicPaths.some((path) => matchesPathPrefix(pathname, path))) {
318
354
  return NextResponse.next();
319
355
  }
356
+ const channelResponse = await channel.proxy(request);
357
+ if (channelResponse) {
358
+ return channelResponse;
359
+ }
320
360
  middleware ??= neonAuth().middleware({ loginUrl: SIGN_IN_PAGE });
321
361
  const response = await middleware(request);
322
362
  if (!isSignInRedirect(request, response)) {
@@ -1,5 +1,4 @@
1
1
  import { headers } from "next/headers";
2
- import { trimTrailingSlashes } from "./config.js";
3
2
  import { isLoopbackHostname } from "./cookies.js";
4
3
  import { SegmentStreamApiError } from "./errors.js";
5
4
  /** Every ontology route lives under this path, beside the app on the same origin. */
@@ -15,6 +14,15 @@ export function createOntologyTransport(options) {
15
14
  const requestHeaders = new Headers(init.headers);
16
15
  const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
17
16
  requestHeaders.set("authorization", `Bearer ${token}`);
17
+ if (token.startsWith("sso_")) {
18
+ // Keep the browser's public app-session resource when a local proxy uses another origin.
19
+ const incoming = await headers();
20
+ const host = incoming.get("x-forwarded-host") ?? incoming.get("host");
21
+ if (host) {
22
+ requestHeaders.set("x-forwarded-host", host);
23
+ requestHeaders.set("x-forwarded-proto", incoming.get("x-forwarded-proto") ?? "https");
24
+ }
25
+ }
18
26
  try {
19
27
  return await options.fetch(url, {
20
28
  ...init,
@@ -65,12 +73,14 @@ export function createOntologyTransport(options) {
65
73
  }
66
74
  export async function ontologyOrigin(configured) {
67
75
  if (configured !== undefined) {
68
- return trimTrailingSlashes(configured);
76
+ return new URL(configured).origin;
69
77
  }
70
78
  // Vercel sets Host to the domain the request arrived on, which routes /api/ontology to the service.
71
79
  const headerStore = await headers();
72
- const host = headerStore.get("host");
73
- if (host === null || host === "") {
80
+ const host = (headerStore.get("x-forwarded-host") ?? headerStore.get("host"))
81
+ ?.split(",")[0]
82
+ ?.trim();
83
+ if (host === undefined || host === "") {
74
84
  throw new SegmentStreamApiError({
75
85
  code: "ontology_url_unknown",
76
86
  status: 500,
@@ -83,7 +93,7 @@ export async function ontologyOrigin(configured) {
83
93
  : isLoopbackHostname(hostnameOf(host))
84
94
  ? "http"
85
95
  : "https";
86
- return `${protocol}://${host}`;
96
+ return new URL(`${protocol}://${host}`).origin;
87
97
  }
88
98
  function hostnameOf(host) {
89
99
  try {
package/dist/proxy.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { NextResponse } from "next/server";
2
- import { trimTrailingSlashes } from "./config.js";
2
+ import { hasChannelApps, trimTrailingSlashes } from "./config.js";
3
3
  import { clearSessionCookies, cookiePolicyFor, replaceRequestCookies, safeReturnTo, setSessionCookies, } from "./cookies.js";
4
4
  import { SDK_ROUTE_PREFIX, isApiRequest, signInRedirect, unauthenticatedResponse, } from "./sign-in.js";
5
5
  export function createProxy(context, options = {}) {
@@ -37,6 +37,9 @@ export function createProxy(context, options = {}) {
37
37
  if (verification.status === "valid") {
38
38
  return NextResponse.next();
39
39
  }
40
+ if (verification.status === "unavailable" && hasChannelApps(context.config.channels)) {
41
+ return sessionUnavailable(context, request, policy);
42
+ }
40
43
  }
41
44
  const refreshToken = request.cookies.get(policy.names.refresh)?.value;
42
45
  if (refreshToken === undefined || refreshToken === "") {
@@ -83,11 +86,12 @@ export function wellKnownResponse(request, ontologyUrl) {
83
86
  return NextResponse.json({ error: "not_found" }, { status: 404 });
84
87
  }
85
88
  /**
86
- * SegmentStream is unreachable. API requests get a retryable 503; pages restart sign-in,
87
- * which ends on the callback's error page rather than looping. Session cookies are kept.
89
+ * SegmentStream is unreachable. APIs and opted-in channel apps get a retryable 503. Other legacy
90
+ * pages restart sign-in, which ends on the callback's error page. Channel apps must not exchange a
91
+ * different identity after failed verification. Session cookies are kept.
88
92
  */
89
93
  async function sessionUnavailable(context, request, policy) {
90
- if (isApiRequest(request)) {
94
+ if (isApiRequest(request) || hasChannelApps(context.config.channels)) {
91
95
  return NextResponse.json({ error: "session_refresh_unavailable" }, { status: 503, headers: { "cache-control": "no-store", "retry-after": "5" } });
92
96
  }
93
97
  return startSignIn(context, request, policy, false);
@@ -0,0 +1,6 @@
1
+ import { type SegmentStreamProxy } from "./proxy.js";
2
+ /**
3
+ * An unauthenticated Telegram Web iframe cannot visit the unframeable sign-in page. Keep the
4
+ * protected URL and return only an exchange script; never let the workspace render anonymously.
5
+ */
6
+ export declare function withTelegramLaunch(proxy: SegmentStreamProxy, channels: Readonly<Record<string, boolean>> | undefined): SegmentStreamProxy;
@@ -0,0 +1,98 @@
1
+ import { NextResponse } from "next/server";
2
+ import { CHANNEL_SESSION_PATH, channelSignInPath } from "./channel-protocol.js";
3
+ import { isDecisionPage } from "./decision-pages.js";
4
+ import { randomToken } from "./pkce.js";
5
+ import { matchesPathPrefix } from "./proxy.js";
6
+ import { channelAppFraming } from "./telegram.js";
7
+ /**
8
+ * An unauthenticated Telegram Web iframe cannot visit the unframeable sign-in page. Keep the
9
+ * protected URL and return only an exchange script; never let the workspace render anonymously.
10
+ */
11
+ export function withTelegramLaunch(proxy, channels) {
12
+ if (channels?.telegram !== true) {
13
+ return proxy;
14
+ }
15
+ return async (request) => {
16
+ const response = await proxy(request);
17
+ const pathname = request.nextUrl.pathname;
18
+ const location = response.headers.get("location");
19
+ const destination = location === null ? null : URL.parse(location, request.url);
20
+ if (request.method !== "GET" ||
21
+ request.headers.get("sec-fetch-dest") !== "iframe" ||
22
+ request.headers.get("sec-fetch-mode") !== "navigate" ||
23
+ isDecisionPage(pathname) ||
24
+ ["/api", "/mcp", "/.well-known", "/_next"].some((path) => matchesPathPrefix(pathname, path)) ||
25
+ response.status < 300 ||
26
+ response.status >= 400 ||
27
+ destination?.origin !== request.nextUrl.origin ||
28
+ destination.pathname !== "/sign-in") {
29
+ return response;
30
+ }
31
+ const nonce = randomToken();
32
+ // Only SDK-owned constants enter the script. Launch data stays in the browser until the POST.
33
+ const script = `
34
+ void (async () => {
35
+ const state = document.documentElement.dataset;
36
+ const fail = (code) => {
37
+ state.channelSignIn = "error";
38
+ state.channelSignInError = code;
39
+ };
40
+ const assertion = new URLSearchParams(location.hash.slice(1)).get("tgWebAppData");
41
+ if (!assertion) {
42
+ state.channelSignIn = "idle";
43
+ return;
44
+ }
45
+ state.channelSignIn = "pending";
46
+ try {
47
+ // Persist only a digest, scoped to this tab and exact launch, before the one allowed reload.
48
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(location.href));
49
+ const reloadKey = "segmentstream:telegram-launch:" + Array.from(new Uint8Array(digest),
50
+ (byte) => byte.toString(16).padStart(2, "0")).join("");
51
+ if (sessionStorage.getItem(reloadKey)) {
52
+ fail("launch_loop");
53
+ return;
54
+ }
55
+ const exchanged = await fetch(${JSON.stringify(channelSignInPath("telegram"))}, {
56
+ method: "POST",
57
+ credentials: "same-origin",
58
+ redirect: "error",
59
+ headers: { "content-type": "application/json" },
60
+ body: JSON.stringify({ assertion }),
61
+ });
62
+ if (!exchanged.ok) {
63
+ const body = await exchanged.json();
64
+ fail(typeof body.error === "string" ? body.error : "sign_in_failed");
65
+ return;
66
+ }
67
+ const discovered = await fetch(${JSON.stringify(CHANNEL_SESSION_PATH)}, {
68
+ credentials: "same-origin",
69
+ cache: "no-store",
70
+ redirect: "error",
71
+ });
72
+ const body = discovered.ok ? await discovered.json() : null;
73
+ if (typeof body?.session?.id !== "string" || typeof body?.user?.id !== "string") {
74
+ fail("session_unavailable");
75
+ return;
76
+ }
77
+ sessionStorage.setItem(reloadKey, "1");
78
+ location.reload();
79
+ } catch {
80
+ fail("sign_in_unavailable");
81
+ }
82
+ })();`;
83
+ const shell = new NextResponse(`<!doctype html><html><head><meta charset="utf-8"></head><body><script nonce="${nonce}">${script}</script></body></html>`, {
84
+ headers: {
85
+ "content-type": "text/html; charset=utf-8",
86
+ "cache-control": "no-store",
87
+ "referrer-policy": "no-referrer",
88
+ "x-content-type-options": "nosniff",
89
+ "content-security-policy": `default-src 'none'; script-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors ${channelAppFraming(channels)}`,
90
+ },
91
+ });
92
+ // Preserve stale-session clearing without carrying redirect or middleware bypass headers.
93
+ for (const cookie of response.headers.getSetCookie()) {
94
+ shell.headers.append("set-cookie", cookie);
95
+ }
96
+ return shell;
97
+ };
98
+ }
@@ -0,0 +1,2 @@
1
+ /** Provider-specific embedding policy; decision pages always override this with DENY. */
2
+ export declare function channelAppFraming(channels: Readonly<Record<string, boolean>> | undefined): string | undefined;
@@ -0,0 +1,6 @@
1
+ /** Provider-specific embedding policy; decision pages always override this with DENY. */
2
+ export function channelAppFraming(channels) {
3
+ return channels?.telegram === true
4
+ ? "'self' https://web.telegram.org https://*.web.telegram.org"
5
+ : undefined;
6
+ }
@@ -17,7 +17,7 @@ export interface CanProps extends ChildrenProps {
17
17
  * ontology service checks every read and action itself.
18
18
  */
19
19
  export declare function Can({ permission, children, fallback }: CanProps): ReactNode;
20
- /** Ends the Neon Auth session in this browser, then opens `redirectTo`. */
20
+ /** Ends the selected session in this browser, then opens `redirectTo`. */
21
21
  export declare function signOut(redirectTo?: string): Promise<void>;
22
22
  export interface UserMenuProps {
23
23
  /** Where to go after signing out. Defaults to `/sign-in`. */
@@ -4,7 +4,7 @@ import { useState } from "react";
4
4
  import { safeReturnTo } from "../return-to.js";
5
5
  import { usePermissions, useSession, useUser } from "./hooks.js";
6
6
  import { navigate } from "./navigation.js";
7
- import { SIGN_IN_PAGE, authClient, clearSession } from "./store.js";
7
+ import { SIGN_IN_PAGE, endBrowserSession, clearSession, getSnapshot } from "./store.js";
8
8
  /** Renders its children when a Neon Auth session exists in this browser. */
9
9
  export function SignedIn({ children }) {
10
10
  return useSession().status === "signed-in" ? children : null;
@@ -24,10 +24,17 @@ export function Can({ permission, children, fallback = null }) {
24
24
  }
25
25
  return can(permission) ? children : fallback;
26
26
  }
27
- /** Ends the Neon Auth session in this browser, then opens `redirectTo`. */
27
+ /** Ends the selected session in this browser, then opens `redirectTo`. */
28
28
  export async function signOut(redirectTo = SIGN_IN_PAGE) {
29
+ const session = getSnapshot().session;
30
+ if (session.status === "signed-in" && session.source === "channel") {
31
+ await endBrowserSession();
32
+ clearSession();
33
+ navigate(safeReturnTo(redirectTo));
34
+ return;
35
+ }
29
36
  try {
30
- await authClient().signOut();
37
+ await endBrowserSession();
31
38
  }
32
39
  finally {
33
40
  clearSession();
package/dist/ui/hooks.js CHANGED
@@ -27,9 +27,9 @@ export function useUser() {
27
27
  const sessionId = session.status === "signed-in" ? session.sessionId : null;
28
28
  useEffect(() => {
29
29
  if (sessionId !== null) {
30
- ensureUser(sessionId);
30
+ ensureUser(sessionId, session.status === "signed-in" ? session.source : undefined);
31
31
  }
32
- }, [sessionId]);
32
+ }, [sessionId, session]);
33
33
  switch (session.status) {
34
34
  case "loading":
35
35
  return LOADING_USER;
@@ -5,6 +5,8 @@ export type { AgentConnectProps } from "./agent-connect.js";
5
5
  export { usePermissions, useSession, useUser } from "./hooks.js";
6
6
  export type { Permissions } from "./hooks.js";
7
7
  export { SignIn } from "./sign-in.js";
8
+ export { TelegramSignIn, useTelegramSignIn } from "./telegram-sign-in.js";
9
+ export type { TelegramSignInProps, TelegramSignInOptions } from "./telegram-sign-in.js";
8
10
  export type { SignInProps } from "./sign-in.js";
9
11
  export { SIGN_IN_ERROR_MESSAGES, signInErrorCode, signInErrorMessage, useSignIn, } from "./use-sign-in.js";
10
12
  export type { SignInController, SignInErrorCode, SignInNoticeCode, SignInOptions, } from "./use-sign-in.js";
@@ -14,3 +16,5 @@ export type { CurrentUser } from "../me.js";
14
16
  export type { AgentPairing } from "../agent.js";
15
17
  export { SegmentStreamApiError, NOT_INVITED, SIGN_IN_LINK_INVALID } from "../errors.js";
16
18
  export { SegmentStreamLogo } from "./segmentstream-logo.js";
19
+ export { useChannelSignIn } from "./use-channel-sign-in.js";
20
+ export type { ChannelSignInState, ChannelSignInOptions } from "./use-channel-sign-in.js";
package/dist/ui/index.js CHANGED
@@ -6,7 +6,9 @@ export { Can, SignedIn, SignedOut, UserMenu, signOut } from "./components.js";
6
6
  export { AgentConnect } from "./agent-connect.js";
7
7
  export { usePermissions, useSession, useUser } from "./hooks.js";
8
8
  export { SignIn } from "./sign-in.js";
9
+ export { TelegramSignIn, useTelegramSignIn } from "./telegram-sign-in.js";
9
10
  export { SIGN_IN_ERROR_MESSAGES, signInErrorCode, signInErrorMessage, useSignIn, } from "./use-sign-in.js";
10
11
  export { isAllowed } from "../permissions.js";
11
12
  export { SegmentStreamApiError, NOT_INVITED, SIGN_IN_LINK_INVALID } from "../errors.js";
12
13
  export { SegmentStreamLogo } from "./segmentstream-logo.js";
14
+ export { useChannelSignIn } from "./use-channel-sign-in.js";