@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 +102 -8
- package/dist/agent.d.ts +2 -1
- package/dist/agent.js +1 -0
- package/dist/app.js +84 -15
- package/dist/channel-protocol.d.ts +16 -0
- package/dist/channel-protocol.js +17 -0
- package/dist/channel-session.d.ts +22 -0
- package/dist/channel-session.js +89 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +11 -1
- package/dist/decision-pages.d.ts +2 -1
- package/dist/decision-pages.js +15 -2
- package/dist/federation.d.ts +6 -0
- package/dist/federation.js +114 -36
- package/dist/handlers.d.ts +3 -1
- package/dist/handlers.js +5 -1
- package/dist/neon.js +43 -3
- package/dist/ontology-transport.js +15 -5
- package/dist/proxy.js +8 -4
- package/dist/telegram-launch.d.ts +6 -0
- package/dist/telegram-launch.js +98 -0
- package/dist/telegram.d.ts +2 -0
- package/dist/telegram.js +6 -0
- package/dist/ui/components.d.ts +1 -1
- package/dist/ui/components.js +10 -3
- package/dist/ui/hooks.js +2 -2
- package/dist/ui/index.d.ts +4 -0
- package/dist/ui/index.js +2 -0
- package/dist/ui/sign-in.d.ts +5 -1
- package/dist/ui/sign-in.js +15 -1
- package/dist/ui/store.d.ts +15 -1
- package/dist/ui/store.js +145 -25
- package/dist/ui/telegram-sign-in.d.ts +16 -0
- package/dist/ui/telegram-sign-in.js +21 -0
- package/dist/ui/use-channel-sign-in.d.ts +10 -0
- package/dist/ui/use-channel-sign-in.js +118 -0
- package/dist/ui/use-sign-in.js +10 -2
- package/package.json +5 -5
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
|
|
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.
|
|
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`)
|
|
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
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
|
|
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) =>
|
|
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
|
|
70
|
-
requireUser:
|
|
71
|
-
|
|
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:
|
|
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 {
|
|
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
|
+
}
|
package/dist/decision-pages.d.ts
CHANGED
|
@@ -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
|
package/dist/decision-pages.js
CHANGED
|
@@ -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 (
|
|
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
|
}
|
package/dist/federation.d.ts
CHANGED
|
@@ -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>;
|