@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.
package/README.md CHANGED
@@ -253,6 +253,92 @@ The calls take values, not form events: call `submitEmail()` and `submitCode()`
253
253
  form's submit handler after `event.preventDefault()`. A second `submitCode()` while one
254
254
  runs is ignored, so the last digit and the Enter key may both submit.
255
255
 
256
+ ## Telegram Mini App sign-in
257
+
258
+ The Mini App is the same app on any page. Enable it explicitly in both SDKs:
259
+
260
+ ```ts
261
+ // App SDK configuration
262
+ createSegmentStreamApp({ ...link, channels: { telegram: true } });
263
+
264
+ // Ontology service configuration (the connection used by chat.telegram)
265
+ createOntologyService(ontology, {
266
+ ...config,
267
+ channels: { telegram: { connection: "chat_bot", miniApp: {} } },
268
+ });
269
+ ```
270
+
271
+ Without these opt-ins, exchange and Telegram framing stay disabled. Providers default to invited:
272
+ paired members can sign in. Workspace apps may explicitly open `signIn.channels.telegram` for new
273
+ accounts and choose their restricted member grants, `onNewUser` action, notice/reply content and
274
+ lower limits. The exchange uses A1's shared admission, limits and onboarding; browser values cannot
275
+ change them. Members without email are managed by member ID.
276
+
277
+ Mount `<TelegramSignIn />` in a client layout on any page, or call `useTelegramSignIn()` and render
278
+ its `{status, error}` codes with app-owned copy. `<SignIn />` automatically exchanges when the app
279
+ opts in and Telegram launch data is present; `renderTelegram` optionally renders its status. No
280
+ dedicated Mini App route is provided. In a top-level WebView, an unauthenticated deep link redirects
281
+ to `/sign-in?returnTo=…`; HTTP redirects inherit Telegram's URL fragment, and the SDK reads
282
+ `tgWebAppData` there. Raw assertions never enter redirect queries, logs or persistent storage.
283
+ For Telegram Web, an unauthenticated iframe navigation instead receives an inert exchange shell
284
+ at the original protected URL. It renders no workspace content and makes one exchange, then checks
285
+ that the browser retained the session cookie before reloading the deep link. This requires the app's
286
+ explicit Telegram opt-in and is limited to iframe GET navigations. Decision pages remain unframeable.
287
+ Missing launch data stays idle; a refusal or failed discovery stops without reloading or falling back
288
+ to another provider. The shell has no UI copy; its `data-channel-sign-in` and
289
+ `data-channel-sign-in-error` attributes expose status and machine codes for inspection.
290
+ If the same launch returns to the shell after its one allowed reload, it stops with `launch_loop`;
291
+ reopen the Mini App with fresh launch data to retry.
292
+ Safari and other browsers without usable CHIPS cannot persist an iframe session; the regular
293
+ sign-in redirect remains DENY inside an iframe, so use a top-level Mini App launch.
294
+ Browsers without Fetch Metadata headers fall back to that DENY sign-in redirect.
295
+ Apps using Telegram's Web App script can also supply fresh data from `Telegram.WebApp.initData`:
296
+
297
+ ```tsx
298
+ "use client";
299
+ import { TelegramSignIn } from "@segmentstream-cloud/app/ui";
300
+
301
+ export function ChannelSession() {
302
+ return <TelegramSignIn />;
303
+ }
304
+ ```
305
+
306
+ The hook posts each fresh assertion once. A single timer renews at the discovered session's
307
+ `expires_at`, without polling. Supply `initData={() => freshRawInitData}` for a custom integration;
308
+ if Telegram still supplies an expired assertion, reopen the Mini App. The app owns refusal text.
309
+
310
+ A valid channel session takes precedence over regular OIDC and Neon sessions on server, proxy and
311
+ browser. Stale channel cookies fall through to regular sign-in. `requireUser`, ontology transport,
312
+ `useSession`, `useUser`, permissions and logout use that selection. JavaScript receives session
313
+ metadata, never the opaque token. `getAccessToken()` remains the legacy SegmentStream credential
314
+ API, and legacy SegmentStream-mode `can()` keeps its previous behavior. Routes live outside
315
+ `/api/auth/*`.
316
+
317
+ Session discovery, configuration and proxy verification/refresh outages remain `unavailable`; they never
318
+ select another provider's user or start an assertion exchange. Retry session discovery or reload
319
+ after recovery. Malformed channel/OIDC identity responses also stop authentication; OIDC discovery
320
+ retains upstream failures instead of reporting a signed-out session. Only confirmed absence or
321
+ refusal is treated as signed out.
322
+
323
+ Browser discovery probes Neon only when the app configures it. Telegram sign-in also works in
324
+ legacy SegmentStream apps without Neon or OIDC. A separate `SEGMENTSTREAM_ONTOLOGY_URL` uses its
325
+ HTTP(S) origin consistently for channel discovery and protected-page validation.
326
+ Public proxy paths and OAuth metadata discovery bypass cookie verification in every authentication
327
+ mode. Unsupported metadata returns 404 without starting sign-in.
328
+
329
+ Sessions last one hour absolutely, without refresh tokens. HTTPS cookies are HttpOnly, Secure,
330
+ SameSite=None and Partitioned. Logout revokes the grant and clears the cookie. Renewal, unpairing
331
+ and pairing displacement revoke only that identity's grants; blocking/deactivation revoke all of
332
+ that member's sessions. Writes, exchange and logout require the app's Origin. Framing by Telegram
333
+ Web is enabled only with app opt-in; sign-in, consent and pairing pages always remain DENY.
334
+
335
+ Before release, manually check Telegram iOS, Android, Desktop and Web: deep-link launch/fragment retention,
336
+ exchange, reload, expiry/fresh data, partitioned-cookie persistence, logout/reopen, mixed sessions
337
+ and revoked/blocked/deactivated members. These checks need a deployed HTTPS app and are separate
338
+ from local fixtures. CI runs the HTTPS iframe suite when Chrome is installed; opt in locally with
339
+ `CHROME_BIN=/usr/bin/google-chrome npm test --workspace packages/app-sdk -- --maxWorkers=1 tests/telegram-launch.browser.test.ts`.
340
+ It uses Chrome and OpenSSL installed on the host and a temporary self-signed certificate.
341
+
256
342
  ## The ontology
257
343
 
258
344
  `segmentstream.ontology.<entity>` calls the app's ontology service at `/api/ontology`
@@ -347,16 +433,19 @@ export const { POST } = segmentstream.agent.connectHandler();
347
433
 
348
434
  `segmentstream.agent` holds:
349
435
 
350
- | Call | Does |
351
- | ---------------------- | ------------------------------------------------------------------------------- |
352
- | `pairing(code)` | `{ provider, display, expiresAt }`; throws `not_found` or `expired` |
353
- | `approvePairing(code)` | Links the account to the signed-in member; returns the `AgentIdentity` |
354
- | `identities()` | The member's own: `{ id, provider, externalId, display, createdAt, revokedAt }` |
355
- | `revokeIdentity(id)` | Revokes one of them at once |
356
- | `connectHandler()` | `{ POST }` for the page's form: same-origin only, takes `code`, answers `303` |
436
+ | Call | Does |
437
+ | ---------------------- | -------------------------------------------------------------------------------------- |
438
+ | `pairing(code)` | `{ provider, display, expiresAt }`; throws `not_found` or `expired` |
439
+ | `approvePairing(code)` | Links the account to the signed-in member; returns the `AgentIdentity` |
440
+ | `identities()` | The member's own: `{ id, provider, externalId, display, createdAt, revokedAt }` |
441
+ | `revokeIdentity(id)` | Revokes one of them at once |
442
+ | `connectHandler()` | `{ POST }` for the page's form: same-origin only; `303` on success; throws on conflict |
357
443
 
358
444
  The handler sends the browser back to `/agent/connect?connected=<provider>`, or to
359
- `/agent/connect?expired=1` when the code was used or is older than ten minutes. Other
445
+ `/agent/connect?expired=1` when the code was used or is older than ten minutes. An account already
446
+ connected to another member throws `SegmentStreamApiError` with status `409` and
447
+ `code === "identity_in_use"` through the app's existing error mechanism. The app owns conflict copy.
448
+ Same-member pairing is idempotent, and replacing your own previous account on a provider is supported. Other
360
449
  refusals, such as `forbidden` for a member the ontology does not admit, throw
361
450
  `SegmentStreamApiError`. All calls are server-only and run as the signed-in member;
362
451
  the ontology decides every one. The workspace starter renders the page with Tailwind CSS
@@ -610,6 +699,11 @@ SegmentStream sets the first three in the app's environment on deploy.
610
699
 
611
700
  ## Local development
612
701
 
702
+ App SDK checks and builds copy the browser-safe channel protocol from
703
+ `packages/ontology-sdk/src/channel-session.ts` into an ignored source module. The
704
+ packed app includes that module and does not require the ontology npm package at
705
+ runtime. Change the canonical ontology source when updating the wire contract.
706
+
613
707
  Put the deployed `NEON_AUTH_BASE_URL` and a cookie secret in `app/.env.local`;
614
708
  Neon Auth trusts `http://localhost:3000`:
615
709
 
package/dist/agent.d.ts CHANGED
@@ -34,7 +34,8 @@ export interface AgentIdentities {
34
34
  readonly pairing: (code: string) => Promise<AgentPairing>;
35
35
  /**
36
36
  * Links the pairing's chat account to the signed-in member. The ontology decides: it refuses a
37
- * used or expired code (`not_found`, `expired`) and a member it does not admit (`forbidden`).
37
+ * used or expired code (`not_found`, `expired`), a member it does not admit (`forbidden`), or an
38
+ * account linked to another member (`identity_in_use`, HTTP 409).
38
39
  * Server-only.
39
40
  */
40
41
  readonly approvePairing: (code: string) => Promise<AgentIdentity>;
package/dist/agent.js CHANGED
@@ -9,6 +9,7 @@ const GONE_CODES = new Set(["not_found", "expired"]);
9
9
  const STATUS_CODES = {
10
10
  403: "forbidden",
11
11
  404: "not_found",
12
+ 409: "identity_in_use",
12
13
  410: "expired",
13
14
  };
14
15
  function pairingPath(code, approve) {
package/dist/app.js CHANGED
@@ -1,7 +1,11 @@
1
1
  import { createNeonAuth } from "@neondatabase/auth/next/server";
2
+ import { redirect } from "next/navigation";
2
3
  import { NextResponse } from "next/server";
3
- import { environmentValue, processEnvironment, resolveConfig, resolveNeonAuth } from "./config.js";
4
+ import { environmentValue, hasChannelApps, processEnvironment, resolveConfig, resolveNeonAuth, } from "./config.js";
4
5
  import { createAgentIdentities } from "./agent.js";
6
+ import { channelAppFraming } from "./telegram.js";
7
+ import { withTelegramLaunch } from "./telegram-launch.js";
8
+ import { createChannelSession } from "./channel-session.js";
5
9
  import { createDataClient } from "./data.js";
6
10
  import { withUnframeablePages } from "./decision-pages.js";
7
11
  import { SegmentStreamApiError } from "./errors.js";
@@ -10,7 +14,8 @@ import { createNeonSession } from "./neon.js";
10
14
  import { createAgentAuthorizations } from "./oauth.js";
11
15
  import { createOntologyClient } from "./ontology.js";
12
16
  import { createOntologyTransport } from "./ontology-transport.js";
13
- import { createProxy } from "./proxy.js";
17
+ import { createProxy, matchesPathPrefix, normalizePublicPath, wellKnownResponse } from "./proxy.js";
18
+ import { SDK_ROUTE_PREFIX } from "./sign-in.js";
14
19
  import { createServerSession } from "./server.js";
15
20
  import { createSessionClient } from "./sessions.js";
16
21
  import { withFederation } from "./federation.js";
@@ -25,19 +30,24 @@ export function createApp(config, dependencies = {}) {
25
30
  const sessionClient = createSessionClient(resolved, fetchImplementation);
26
31
  const issuer = environmentValue(environment, "SEGMENTSTREAM_OIDC_ISSUER");
27
32
  const clientId = environmentValue(environment, "SEGMENTSTREAM_OIDC_CLIENT_ID");
28
- const federated = (app, proofTransport) => {
33
+ const withChannelLaunch = (app) => ({
34
+ ...app,
35
+ proxy: (options) => withTelegramLaunch(app.proxy(options), resolved.channels),
36
+ });
37
+ const federated = (app, proofTransport, channelSession) => {
29
38
  if (!issuer && !clientId) {
30
- return app;
39
+ return withChannelLaunch(app);
31
40
  }
32
41
  if (!issuer || !clientId) {
33
42
  throw new Error("SEGMENTSTREAM_OIDC_ISSUER and SEGMENTSTREAM_OIDC_CLIENT_ID must be configured together.");
34
43
  }
35
- return withFederation(app, resolved, { issuer, clientId }, {
44
+ return withChannelLaunch(withFederation(app, resolved, { issuer, clientId }, {
36
45
  fetch: fetchImplementation,
37
46
  ontologyUrl: environmentValue(environment, "SEGMENTSTREAM_ONTOLOGY_URL"),
38
47
  neon: neonAuth !== null,
39
48
  proofTransport,
40
- });
49
+ channelSession,
50
+ }));
41
51
  };
42
52
  const context = {
43
53
  config: resolved,
@@ -55,22 +65,81 @@ export function createApp(config, dependencies = {}) {
55
65
  getAppCredential,
56
66
  });
57
67
  const ontologyUrl = environmentValue(environment, "SEGMENTSTREAM_ONTOLOGY_URL");
68
+ const channel = createChannelSession({
69
+ enabled: hasChannelApps(resolved.channels),
70
+ workspaceId: resolved.workspaceId,
71
+ ontologyUrl,
72
+ fetch: fetchImplementation,
73
+ });
74
+ const getChannelUser = async () => {
75
+ const session = await channel.read();
76
+ return session
77
+ ? {
78
+ ...session.user,
79
+ email: session.user.email ?? undefined,
80
+ name: session.user.name ?? undefined,
81
+ organizationId: resolved.organizationId,
82
+ workspaceId: resolved.workspaceId,
83
+ }
84
+ : null;
85
+ };
86
+ const getUser = async () => (await getChannelUser()) ?? legacy.getUser();
58
87
  // The ontology service accepts SegmentStream app tokens while workspaces move to Neon Auth.
59
88
  const ontologyTransport = createOntologyTransport({
60
89
  fetch: fetchImplementation,
61
90
  ontologyUrl: ontologyUrl === undefined ? undefined : new URL(ontologyUrl).origin,
62
- credentials: { token: legacy.getAccessToken },
91
+ credentials: { token: async () => (await channel.read())?.token ?? legacy.getAccessToken() },
63
92
  });
64
93
  return federated({
65
94
  authentication: "segmentstream",
66
- proxy: (options) => withUnframeablePages(createProxy(context, options)),
95
+ proxy: (options) => {
96
+ const primaryProxy = createProxy(context, options);
97
+ const publicPaths = [SDK_ROUTE_PREFIX, ...(options?.publicPaths ?? [])].map(normalizePublicPath);
98
+ return withUnframeablePages(async (request) => {
99
+ const discovery = wellKnownResponse(request, ontologyUrl);
100
+ if (discovery !== undefined) {
101
+ return discovery;
102
+ }
103
+ if (request.nextUrl.pathname.startsWith("/api/ontology/")) {
104
+ return ontologyUrl
105
+ ? NextResponse.rewrite(new URL(`${request.nextUrl.pathname}${request.nextUrl.search}`, ontologyUrl))
106
+ : NextResponse.next();
107
+ }
108
+ if (hasChannelApps(resolved.channels) && request.nextUrl.pathname === "/sign-in") {
109
+ return NextResponse.next();
110
+ }
111
+ if (publicPaths.some((path) => matchesPathPrefix(request.nextUrl.pathname, path))) {
112
+ return primaryProxy(request);
113
+ }
114
+ const response = (await channel.proxy(request)) ?? (await primaryProxy(request));
115
+ if (hasChannelApps(resolved.channels) &&
116
+ response.headers.has("location") &&
117
+ !request.nextUrl.pathname.startsWith("/api/")) {
118
+ const destination = new URL("/sign-in", request.url);
119
+ destination.searchParams.set("returnTo", request.nextUrl.pathname + request.nextUrl.search);
120
+ response.headers.set("location", destination.href);
121
+ }
122
+ return response;
123
+ }, channelAppFraming(resolved.channels));
124
+ },
67
125
  authHandlers: notConfiguredHandlers,
68
126
  handlers: () => createHandlers(context),
69
- getUser: legacy.getUser,
70
- requireUser: legacy.requireUser,
71
- // Ontology permissions come with Neon Auth; the legacy sign-in grants none.
127
+ getUser,
128
+ requireUser: async () => {
129
+ if (!hasChannelApps(resolved.channels) && !(await channel.token())) {
130
+ return legacy.requireUser();
131
+ }
132
+ const user = await getUser();
133
+ if (!user) {
134
+ redirect("/sign-in");
135
+ }
136
+ return user;
137
+ },
72
138
  can: async () => false,
73
- signOut: legacy.signOut,
139
+ signOut: async (options) => {
140
+ await channel.signOut();
141
+ return legacy.signOut(options);
142
+ },
74
143
  getAccessToken: legacy.getAccessToken,
75
144
  fetch: data.fetch,
76
145
  warehouse: data.warehouse,
@@ -78,7 +147,7 @@ export function createApp(config, dependencies = {}) {
78
147
  ontology: createOntologyClient(ontologyTransport),
79
148
  oauth: createAgentAuthorizations(ontologyTransport),
80
149
  agent: createAgentIdentities(ontologyTransport),
81
- });
150
+ }, undefined, { getUser: getChannelUser, signOut: channel.signOut, transport: channel.transport });
82
151
  }
83
152
  const neon = createNeonSession({
84
153
  config: resolved,
@@ -95,9 +164,9 @@ export function createApp(config, dependencies = {}) {
95
164
  });
96
165
  return federated({
97
166
  authentication: "neon-auth",
98
- proxy: (options) => withUnframeablePages(neon.proxy(options)),
167
+ proxy: (options) => withUnframeablePages(neon.proxy(options), channelAppFraming(resolved.channels)),
99
168
  authHandlers: neon.authHandlers,
100
- handlers: () => createHandlers(context),
169
+ handlers: () => createHandlers(context, { neon: true }),
101
170
  getUser: neon.getUser,
102
171
  requireUser: neon.requireUser,
103
172
  can: neon.can,
@@ -0,0 +1,16 @@
1
+ /** Browser-safe wire protocol shared by the ontology service and app SDK. */
2
+ export declare const CHANNEL_SESSION_PATH = "/api/ontology/session";
3
+ export declare const CHANNEL_LOGOUT_PATH = "/api/ontology/sign-in/logout";
4
+ export declare const CHANNEL_SESSION_TTL_SECONDS = 3600;
5
+ export declare const channelSignInPath: (provider: string) => string;
6
+ export declare const channelClientId: (provider: string) => string;
7
+ export declare const channelCookieName: (workspaceId: string) => string;
8
+ export declare const CHANNEL_COOKIE_PATTERN: RegExp;
9
+ export declare const channelCookieAttributes: {
10
+ httpOnly: boolean;
11
+ secure: boolean;
12
+ sameSite: "none";
13
+ partitioned: boolean;
14
+ path: string;
15
+ };
16
+ export declare const channelCookie: (name: string, token: string, maxAge: number) => string;
@@ -0,0 +1,17 @@
1
+ // Generated from packages/ontology-sdk/src/channel-session.ts; do not edit.
2
+ /** Browser-safe wire protocol shared by the ontology service and app SDK. */
3
+ export const CHANNEL_SESSION_PATH = "/api/ontology/session";
4
+ export const CHANNEL_LOGOUT_PATH = "/api/ontology/sign-in/logout";
5
+ export const CHANNEL_SESSION_TTL_SECONDS = 3600;
6
+ export const channelSignInPath = (provider) => `/api/ontology/sign-in/${encodeURIComponent(provider)}`;
7
+ export const channelClientId = (provider) => `${provider}-mini-app`;
8
+ export const channelCookieName = (workspaceId) => `__Host-ss_channel_session.${workspaceId.replace(/[^A-Za-z0-9_-]/g, "_")}`;
9
+ export const CHANNEL_COOKIE_PATTERN = /^__Host-ss_channel_session\.[A-Za-z0-9_-]+$/;
10
+ export const channelCookieAttributes = {
11
+ httpOnly: true,
12
+ secure: true,
13
+ sameSite: "none",
14
+ partitioned: true,
15
+ path: "/",
16
+ };
17
+ export const channelCookie = (name, token, maxAge) => `${name}=${token}; HttpOnly; Secure; SameSite=None; Partitioned; Path=/; Max-Age=${maxAge}`;
@@ -0,0 +1,22 @@
1
+ import { NextResponse } from "next/server";
2
+ import type { NextRequest } from "next/server";
3
+ import { type CurrentUser } from "./me.js";
4
+ import type { Fetch } from "./sessions.js";
5
+ export { channelCookieName } from "./channel-protocol.js";
6
+ export interface ChannelSessionIdentity {
7
+ readonly token: string;
8
+ readonly user: CurrentUser;
9
+ }
10
+ /** Workspace-owned opaque app sessions; every discovery checks current membership and revocation. */
11
+ export declare function createChannelSession(options: {
12
+ readonly enabled?: boolean;
13
+ readonly workspaceId: string;
14
+ readonly ontologyUrl: string | undefined;
15
+ readonly fetch: Fetch;
16
+ }): {
17
+ token: () => Promise<string | null>;
18
+ transport: import("./ontology.js").OntologyTransport;
19
+ read: () => Promise<ChannelSessionIdentity | null>;
20
+ signOut: () => Promise<void>;
21
+ proxy: (request: NextRequest) => Promise<NextResponse | undefined>;
22
+ };
@@ -0,0 +1,89 @@
1
+ import { cookies } from "next/headers";
2
+ import { NextResponse } from "next/server";
3
+ import { CHANNEL_LOGOUT_PATH, channelCookieName, channelCookieAttributes, } from "./channel-protocol.js";
4
+ import { apiErrorFrom, readJson, SegmentStreamApiError } from "./errors.js";
5
+ import { isSameOrigin } from "./decision-pages.js";
6
+ import { parseMe } from "./me.js";
7
+ import { createOntologyTransport, ontologyOrigin } from "./ontology-transport.js";
8
+ export { channelCookieName } from "./channel-protocol.js";
9
+ /** Workspace-owned opaque app sessions; every discovery checks current membership and revocation. */
10
+ export function createChannelSession(options) {
11
+ const name = channelCookieName(options.workspaceId);
12
+ const token = async () => {
13
+ if (!options.enabled) {
14
+ return null;
15
+ }
16
+ const credential = (await cookies()).get(name)?.value;
17
+ return credential?.startsWith("sso_") ? credential : null;
18
+ };
19
+ const transport = createOntologyTransport({
20
+ ...options,
21
+ credentials: { token },
22
+ });
23
+ const read = async () => {
24
+ const credential = await token();
25
+ if (!credential) {
26
+ return null;
27
+ }
28
+ const response = await transport.fetch("/me", { method: "GET" });
29
+ if (response.status === 401 || response.status === 403) {
30
+ return null;
31
+ }
32
+ const payload = await readJson(response);
33
+ if (!response.ok) {
34
+ throw apiErrorFrom(response.status, payload, "session_unavailable");
35
+ }
36
+ const user = parseMe(payload);
37
+ if (!user) {
38
+ throw new SegmentStreamApiError({
39
+ code: "ontology_invalid_response",
40
+ status: 502,
41
+ detail: "ontology_invalid_response",
42
+ });
43
+ }
44
+ return { token: credential, user };
45
+ };
46
+ const signOut = async () => {
47
+ if (!(await token())) {
48
+ return;
49
+ }
50
+ const origin = await ontologyOrigin(undefined);
51
+ const response = await transport.fetch(CHANNEL_LOGOUT_PATH.replace("/api/ontology", ""), {
52
+ method: "POST",
53
+ headers: { origin },
54
+ });
55
+ if (!response.ok) {
56
+ throw apiErrorFrom(response.status, await readJson(response), "logout_failed");
57
+ }
58
+ (await cookies()).set(name, "", { ...channelCookieAttributes, maxAge: 0 });
59
+ };
60
+ const proxy = async (request) => {
61
+ if (!options.enabled) {
62
+ return undefined;
63
+ }
64
+ const credential = request.cookies.get(name)?.value;
65
+ if (!credential?.startsWith("sso_")) {
66
+ return undefined;
67
+ }
68
+ if (!["GET", "HEAD", "OPTIONS"].includes(request.method) && !isSameOrigin(request)) {
69
+ return NextResponse.json({ error: "cross_origin_session" }, { status: 403 });
70
+ }
71
+ const response = await options.fetch(`${await ontologyOrigin(options.ontologyUrl ?? request.nextUrl.origin)}/api/ontology/me`, {
72
+ headers: {
73
+ authorization: `Bearer ${credential}`,
74
+ "x-forwarded-host": request.nextUrl.host,
75
+ "x-forwarded-proto": request.nextUrl.protocol.slice(0, -1),
76
+ },
77
+ cache: "no-store",
78
+ redirect: "error",
79
+ });
80
+ if (response.ok && parseMe(await readJson(response))) {
81
+ return NextResponse.next();
82
+ }
83
+ if (response.status !== 401 && response.status !== 403) {
84
+ return NextResponse.json({ error: "session_unavailable" }, { status: 503 });
85
+ }
86
+ return undefined;
87
+ };
88
+ return { token, transport, read, signOut, proxy };
89
+ }
package/dist/config.d.ts CHANGED
@@ -6,12 +6,15 @@ export interface SegmentStreamAppConfig {
6
6
  readonly organizationId: string;
7
7
  /** SegmentStream origin. Defaults to `SEGMENTSTREAM_URL`, then `https://cloud.segmentstream.com`. */
8
8
  readonly baseUrl?: string;
9
+ /** Explicit channel app opt-in; omitted providers stay disabled. */
10
+ readonly channels?: Readonly<Record<string, boolean>>;
9
11
  }
10
12
  export interface ResolvedConfig {
11
13
  readonly workspaceId: string;
12
14
  readonly organizationId: string;
13
15
  readonly baseUrl: string;
14
16
  readonly issuer: string;
17
+ readonly channels?: Readonly<Record<string, boolean>>;
15
18
  }
16
19
  /** Environment variables, `process.env` unless a test supplies its own. */
17
20
  export type Environment = Readonly<Record<string, string | undefined>>;
@@ -32,3 +35,5 @@ export declare function resolveNeonAuth(environment: Environment): NeonAuthEnvir
32
35
  export declare function trimTrailingSlashes(value: string): string;
33
36
  /** A variable's trimmed value; unset and blank are the same. */
34
37
  export declare function environmentValue(environment: Environment, name: string): string | undefined;
38
+ /** Opt-ins are booleans, separate from ontology admission policy. */
39
+ export declare function hasChannelApps(channels: Readonly<Record<string, boolean>> | undefined): boolean;
package/dist/config.js CHANGED
@@ -7,7 +7,13 @@ export function resolveConfig(config, environment = processEnvironment()) {
7
7
  const organizationId = requireIdentifier("organizationId", config.organizationId);
8
8
  const baseUrl = normalizeBaseUrl(config.baseUrl ?? environmentValue(environment, "SEGMENTSTREAM_URL") ?? DEFAULT_BASE_URL);
9
9
  const issuer = environmentValue(environment, "SEGMENTSTREAM_APP_TOKEN_ISSUER") ?? new URL(baseUrl).origin;
10
- return { workspaceId, organizationId, baseUrl, issuer };
10
+ return {
11
+ workspaceId,
12
+ organizationId,
13
+ baseUrl,
14
+ issuer,
15
+ ...(config.channels ? { channels: config.channels } : {}),
16
+ };
11
17
  }
12
18
  /** `null` without `NEON_AUTH_BASE_URL`: the app then signs in with SegmentStream, as in 0.2. */
13
19
  export function resolveNeonAuth(environment) {
@@ -61,3 +67,7 @@ export function environmentValue(environment, name) {
61
67
  const value = environment[name];
62
68
  return value === undefined || value.trim() === "" ? undefined : value.trim();
63
69
  }
70
+ /** Opt-ins are booleans, separate from ontology admission policy. */
71
+ export function hasChannelApps(channels) {
72
+ return Object.values(channels ?? {}).some((enabled) => enabled === true);
73
+ }
@@ -1,10 +1,11 @@
1
1
  import { type SegmentStreamProxy } from "./proxy.js";
2
+ export declare function isDecisionPage(pathname: string): boolean;
2
3
  /**
3
4
  * The proxy, with the decision pages unframeable: no other site may show them in a frame and trick
4
5
  * a member into clicking Allow or Approve. Every app gets this from the SDK, whatever its Next.js
5
6
  * config says.
6
7
  */
7
- export declare function withUnframeablePages(proxy: SegmentStreamProxy): SegmentStreamProxy;
8
+ export declare function withUnframeablePages(proxy: SegmentStreamProxy, frameAncestors?: string): SegmentStreamProxy;
8
9
  /**
9
10
  * Whether a form post came from the app's own pages. A decision must never be made from another
10
11
  * site, which could otherwise approve an agent in the member's name. Fetch Metadata describes
@@ -3,16 +3,19 @@ import { AGENT_CONNECT_PAGE, CONSENT_PAGE } from "./pages.js";
3
3
  import { matchesPathPrefix } from "./proxy.js";
4
4
  /** Pages where a signed-in member lets an agent act for them. */
5
5
  const DECISION_PAGES = [CONSENT_PAGE, AGENT_CONNECT_PAGE, "/sign-in", "/api/oauth/consent"];
6
+ export function isDecisionPage(pathname) {
7
+ return DECISION_PAGES.some((page) => matchesPathPrefix(pathname, page));
8
+ }
6
9
  /**
7
10
  * The proxy, with the decision pages unframeable: no other site may show them in a frame and trick
8
11
  * a member into clicking Allow or Approve. Every app gets this from the SDK, whatever its Next.js
9
12
  * config says.
10
13
  */
11
- export function withUnframeablePages(proxy) {
14
+ export function withUnframeablePages(proxy, frameAncestors) {
12
15
  return async (request) => {
13
16
  const response = await proxy(request);
14
17
  const pathname = request.nextUrl.pathname;
15
- if (DECISION_PAGES.some((page) => matchesPathPrefix(pathname, page))) {
18
+ if (isDecisionPage(pathname)) {
16
19
  response.headers.set("cache-control", "no-store");
17
20
  response.headers.set("referrer-policy", "no-referrer");
18
21
  response.headers.set("x-frame-options", "DENY");
@@ -22,6 +25,16 @@ export function withUnframeablePages(proxy) {
22
25
  : policy.replace(/frame-ancestors[^;]*/g, "frame-ancestors 'none'") +
23
26
  (policy.includes("frame-ancestors") ? "" : "; frame-ancestors 'none'"));
24
27
  }
28
+ else if (frameAncestors &&
29
+ !["/api", "/mcp", "/.well-known", "/_next"].some((path) => matchesPathPrefix(pathname, path))) {
30
+ const policy = response.headers.get("content-security-policy");
31
+ const ancestors = `frame-ancestors ${frameAncestors}`;
32
+ response.headers.delete("x-frame-options");
33
+ response.headers.set("content-security-policy", policy === null
34
+ ? ancestors
35
+ : policy.replace(/frame-ancestors[^;]*/g, ancestors) +
36
+ (policy.includes("frame-ancestors") ? "" : `; ${ancestors}`));
37
+ }
25
38
  return response;
26
39
  };
27
40
  }
@@ -2,6 +2,7 @@ import type { SegmentStreamApp } from "./app.js";
2
2
  import type { ResolvedConfig } from "./config.js";
3
3
  import { type OntologyTransport } from "./ontology.js";
4
4
  import type { Fetch } from "./sessions.js";
5
+ import type { AppUser } from "./tokens.js";
5
6
  /** Public-client RP. Only workspace-owned opaque sessions are stored in its session cookie. */
6
7
  export declare function withFederation<O>(base: SegmentStreamApp<O>, config: ResolvedConfig, oidc: {
7
8
  issuer: string;
@@ -11,4 +12,9 @@ export declare function withFederation<O>(base: SegmentStreamApp<O>, config: Res
11
12
  ontologyUrl?: string;
12
13
  neon: boolean;
13
14
  proofTransport?: OntologyTransport;
15
+ channelSession?: {
16
+ readonly getUser: () => Promise<AppUser | null>;
17
+ readonly signOut: () => Promise<void>;
18
+ readonly transport: OntologyTransport;
19
+ };
14
20
  }): SegmentStreamApp<O>;