@segmentstream/app 0.0.0-stage → 0.38.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/LICENSE +201 -0
- package/NOTICE +2 -0
- package/README.md +1236 -2
- package/dist/agent.d.ts +56 -0
- package/dist/agent.js +203 -0
- package/dist/app.d.ts +82 -0
- package/dist/app.js +434 -0
- package/dist/browser-auth-capability.d.ts +2 -0
- package/dist/browser-auth-capability.js +2 -0
- package/dist/browser-revision.d.ts +19 -0
- package/dist/browser-revision.js +140 -0
- 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 +53 -0
- package/dist/config.js +112 -0
- package/dist/context.d.ts +10 -0
- package/dist/context.js +1 -0
- package/dist/cookies.d.ts +56 -0
- package/dist/cookies.js +131 -0
- package/dist/data.d.ts +233 -0
- package/dist/data.js +206 -0
- package/dist/decision-pages.d.ts +14 -0
- package/dist/decision-pages.js +87 -0
- package/dist/deployment.d.ts +30 -0
- package/dist/deployment.js +72 -0
- package/dist/errors.d.ts +60 -0
- package/dist/errors.js +123 -0
- package/dist/federation.d.ts +24 -0
- package/dist/federation.js +613 -0
- package/dist/groups.d.ts +95 -0
- package/dist/groups.js +280 -0
- package/dist/handlers.d.ts +10 -0
- package/dist/handlers.js +161 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +29 -0
- package/dist/jobs.d.ts +49 -0
- package/dist/jobs.js +40 -0
- package/dist/legacy.d.ts +77 -0
- package/dist/legacy.js +57 -0
- package/dist/logo.d.ts +1 -0
- package/dist/logo.js +25 -0
- package/dist/me.d.ts +42 -0
- package/dist/me.js +54 -0
- package/dist/navigation/build.d.ts +68 -0
- package/dist/navigation/build.js +290 -0
- package/dist/navigation/cli.d.ts +19 -0
- package/dist/navigation/cli.js +37 -0
- package/dist/navigation/generate.d.ts +43 -0
- package/dist/navigation/generate.js +138 -0
- package/dist/navigation/next.d.ts +10 -0
- package/dist/navigation/next.js +18 -0
- package/dist/navigation-protocol.d.ts +55 -0
- package/dist/navigation-protocol.js +298 -0
- package/dist/neon.d.ts +101 -0
- package/dist/neon.js +539 -0
- package/dist/oauth-completion.d.ts +6 -0
- package/dist/oauth-completion.js +106 -0
- package/dist/oauth.d.ts +59 -0
- package/dist/oauth.js +129 -0
- package/dist/ontology-names.d.ts +10 -0
- package/dist/ontology-names.js +38 -0
- package/dist/ontology-transport.d.ts +34 -0
- package/dist/ontology-transport.js +130 -0
- package/dist/ontology-wire.d.ts +66 -0
- package/dist/ontology-wire.js +355 -0
- package/dist/ontology.d.ts +381 -0
- package/dist/ontology.js +367 -0
- package/dist/pages.d.ts +6 -0
- package/dist/pages.js +6 -0
- package/dist/parameters.d.ts +55 -0
- package/dist/parameters.js +118 -0
- package/dist/permissions.d.ts +19 -0
- package/dist/permissions.js +55 -0
- package/dist/pkce.d.ts +8 -0
- package/dist/pkce.js +40 -0
- package/dist/proxy.d.ts +25 -0
- package/dist/proxy.js +135 -0
- package/dist/request-timing.d.ts +6 -0
- package/dist/request-timing.js +53 -0
- package/dist/return-to.d.ts +2 -0
- package/dist/return-to.js +24 -0
- package/dist/server.d.ts +11 -0
- package/dist/server.js +80 -0
- package/dist/sessions.d.ts +27 -0
- package/dist/sessions.js +72 -0
- package/dist/sign-in.d.ts +37 -0
- package/dist/sign-in.js +95 -0
- package/dist/storage.d.ts +79 -0
- package/dist/storage.js +180 -0
- package/dist/telegram-launch.d.ts +7 -0
- package/dist/telegram-launch.js +134 -0
- package/dist/telegram.d.ts +2 -0
- package/dist/telegram.js +6 -0
- package/dist/tokens.d.ts +51 -0
- package/dist/tokens.js +70 -0
- package/dist/ui/agent-connect.d.ts +18 -0
- package/dist/ui/agent-connect.js +23 -0
- package/dist/ui/components.d.ts +27 -0
- package/dist/ui/components.js +71 -0
- package/dist/ui/hooks.d.ts +19 -0
- package/dist/ui/hooks.js +56 -0
- package/dist/ui/index.d.ts +22 -0
- package/dist/ui/index.js +15 -0
- package/dist/ui/navigation.d.ts +5 -0
- package/dist/ui/navigation.js +8 -0
- package/dist/ui/segmentstream-logo.d.ts +2 -0
- package/dist/ui/segmentstream-logo.js +7 -0
- package/dist/ui/sign-in-check.d.ts +14 -0
- package/dist/ui/sign-in-check.js +41 -0
- package/dist/ui/sign-in.d.ts +17 -0
- package/dist/ui/sign-in.js +65 -0
- package/dist/ui/store.d.ts +80 -0
- package/dist/ui/store.js +280 -0
- package/dist/ui/styles.css +249 -0
- package/dist/ui/telegram-sign-in.d.ts +16 -0
- package/dist/ui/telegram-sign-in.js +21 -0
- package/dist/ui/use-browser-sign-in.d.ts +52 -0
- package/dist/ui/use-browser-sign-in.js +169 -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.d.ts +109 -0
- package/dist/ui/use-sign-in.js +304 -0
- package/package.json +95 -4
package/dist/pkce.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Web Crypto only, so the same code runs in the Node.js and Edge runtimes.
|
|
2
|
+
export function base64UrlEncode(bytes) {
|
|
3
|
+
let binary = "";
|
|
4
|
+
for (const byte of bytes) {
|
|
5
|
+
binary += String.fromCharCode(byte);
|
|
6
|
+
}
|
|
7
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
8
|
+
}
|
|
9
|
+
export function base64UrlDecode(value) {
|
|
10
|
+
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
11
|
+
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
|
|
12
|
+
const binary = atob(padded);
|
|
13
|
+
const bytes = new Uint8Array(binary.length);
|
|
14
|
+
for (let index = 0; index < binary.length; index += 1) {
|
|
15
|
+
bytes[index] = binary.charCodeAt(index);
|
|
16
|
+
}
|
|
17
|
+
return bytes;
|
|
18
|
+
}
|
|
19
|
+
/** A URL-safe random value carrying `byteLength` bytes of entropy. */
|
|
20
|
+
export function randomToken(byteLength = 32) {
|
|
21
|
+
const bytes = new Uint8Array(byteLength);
|
|
22
|
+
crypto.getRandomValues(bytes);
|
|
23
|
+
return base64UrlEncode(bytes);
|
|
24
|
+
}
|
|
25
|
+
/** The RFC 7636 S256 code challenge for a code verifier. */
|
|
26
|
+
export async function createCodeChallenge(codeVerifier) {
|
|
27
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier));
|
|
28
|
+
return base64UrlEncode(new Uint8Array(digest));
|
|
29
|
+
}
|
|
30
|
+
/** Compares two strings without short-circuiting on the first differing character. */
|
|
31
|
+
export function constantTimeEqual(left, right) {
|
|
32
|
+
if (left.length !== right.length) {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
let difference = 0;
|
|
36
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
37
|
+
difference |= left.charCodeAt(index) ^ right.charCodeAt(index);
|
|
38
|
+
}
|
|
39
|
+
return difference === 0;
|
|
40
|
+
}
|
package/dist/proxy.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import type { NextRequest } from "next/server";
|
|
3
|
+
import type { AppContext } from "./context.js";
|
|
4
|
+
export interface ProxyOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Paths that do not require a signed-in user. Each entry matches itself and everything
|
|
7
|
+
* below it on path-segment boundaries: `/health` matches `/health` and `/health/db`,
|
|
8
|
+
* not `/healthy`.
|
|
9
|
+
*/
|
|
10
|
+
readonly publicPaths?: readonly string[];
|
|
11
|
+
/**
|
|
12
|
+
* An app page that a Telegram Web launch opens when it cannot sign in, for example because the
|
|
13
|
+
* browser refuses the session cookie inside Telegram's frame. Telegram must be enabled in
|
|
14
|
+
* `channels`, and the path must be covered by `publicPaths`. The page receives `error`, the
|
|
15
|
+
* machine code, and `returnTo`, the launched path and query without Telegram's launch data.
|
|
16
|
+
* Without it, a failed launch stays on a blank page.
|
|
17
|
+
*/
|
|
18
|
+
readonly channelLaunchFailurePath?: string;
|
|
19
|
+
}
|
|
20
|
+
export type SegmentStreamProxy = (request: NextRequest) => Promise<NextResponse>;
|
|
21
|
+
export declare function createProxy(context: AppContext, options?: ProxyOptions): SegmentStreamProxy;
|
|
22
|
+
/** Discovery probes never start browser authentication, including probes for unsupported metadata. */
|
|
23
|
+
export declare function wellKnownResponse(request: NextRequest, ontologyUrl?: string): NextResponse | undefined;
|
|
24
|
+
export declare function normalizePublicPath(path: string): string;
|
|
25
|
+
export declare function matchesPathPrefix(pathname: string, prefix: string): boolean;
|
package/dist/proxy.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { hasChannelApps, trimTrailingSlashes } from "./config.js";
|
|
3
|
+
import { clearSessionCookies, cookiePolicyFor, replaceRequestCookies, safeReturnTo, setSessionCookies, } from "./cookies.js";
|
|
4
|
+
import { SDK_ROUTE_PREFIX, isApiRequest, signInRedirect, unauthenticatedResponse, } from "./sign-in.js";
|
|
5
|
+
export function createProxy(context, options = {}) {
|
|
6
|
+
const publicPaths = (options.publicPaths ?? []).map(normalizePublicPath);
|
|
7
|
+
const refreshes = new Map();
|
|
8
|
+
/**
|
|
9
|
+
* A page load and the prefetches it triggers arrive together with the same expired
|
|
10
|
+
* session. They share one refresh instead of each spending the refresh token.
|
|
11
|
+
*/
|
|
12
|
+
function refreshSession(refreshToken) {
|
|
13
|
+
const pending = refreshes.get(refreshToken);
|
|
14
|
+
if (pending !== undefined) {
|
|
15
|
+
return pending;
|
|
16
|
+
}
|
|
17
|
+
const exchange = context
|
|
18
|
+
.requestToken({ grantType: "refresh_token", refreshToken })
|
|
19
|
+
.finally(() => refreshes.delete(refreshToken));
|
|
20
|
+
refreshes.set(refreshToken, exchange);
|
|
21
|
+
return exchange;
|
|
22
|
+
}
|
|
23
|
+
return async function proxy(request) {
|
|
24
|
+
const pathname = request.nextUrl.pathname;
|
|
25
|
+
const discovery = wellKnownResponse(request);
|
|
26
|
+
if (discovery !== undefined) {
|
|
27
|
+
return discovery;
|
|
28
|
+
}
|
|
29
|
+
if (matchesPathPrefix(pathname, SDK_ROUTE_PREFIX) ||
|
|
30
|
+
publicPaths.some((path) => matchesPathPrefix(pathname, path))) {
|
|
31
|
+
return NextResponse.next();
|
|
32
|
+
}
|
|
33
|
+
const policy = cookiePolicyFor(request.nextUrl, context.config.workspaceId);
|
|
34
|
+
const accessToken = request.cookies.get(policy.names.session)?.value;
|
|
35
|
+
if (accessToken !== undefined && accessToken !== "") {
|
|
36
|
+
const verification = await context.verifyToken(accessToken);
|
|
37
|
+
if (verification.status === "valid") {
|
|
38
|
+
return NextResponse.next();
|
|
39
|
+
}
|
|
40
|
+
if (verification.status === "unavailable" && hasChannelApps(context.config.channels)) {
|
|
41
|
+
return sessionUnavailable(context, request, policy);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const refreshToken = request.cookies.get(policy.names.refresh)?.value;
|
|
45
|
+
if (refreshToken === undefined || refreshToken === "") {
|
|
46
|
+
return startSignIn(context, request, policy, accessToken !== undefined);
|
|
47
|
+
}
|
|
48
|
+
const exchange = await refreshSession(refreshToken);
|
|
49
|
+
if (exchange.status === "rejected") {
|
|
50
|
+
return startSignIn(context, request, policy, true);
|
|
51
|
+
}
|
|
52
|
+
if (exchange.status === "unavailable") {
|
|
53
|
+
return sessionUnavailable(context, request, policy);
|
|
54
|
+
}
|
|
55
|
+
const verification = await context.verifyToken(exchange.tokens.accessToken);
|
|
56
|
+
if (verification.status === "valid") {
|
|
57
|
+
return continueWithSession(request, policy, exchange.tokens);
|
|
58
|
+
}
|
|
59
|
+
if (verification.status === "unavailable") {
|
|
60
|
+
// The refresh token was rotated; keep the new one even though this request cannot proceed.
|
|
61
|
+
const response = await sessionUnavailable(context, request, policy);
|
|
62
|
+
setSessionCookies(response, policy, exchange.tokens);
|
|
63
|
+
return response;
|
|
64
|
+
}
|
|
65
|
+
return startSignIn(context, request, policy, true);
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const ONTOLOGY_METADATA_PATHS = new Set([
|
|
69
|
+
"/.well-known/oauth-protected-resource",
|
|
70
|
+
"/.well-known/oauth-protected-resource/mcp",
|
|
71
|
+
"/.well-known/oauth-authorization-server",
|
|
72
|
+
]);
|
|
73
|
+
/** Discovery probes never start browser authentication, including probes for unsupported metadata. */
|
|
74
|
+
export function wellKnownResponse(request, ontologyUrl) {
|
|
75
|
+
const { pathname, search } = request.nextUrl;
|
|
76
|
+
if (!matchesPathPrefix(pathname, "/.well-known")) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
if (ONTOLOGY_METADATA_PATHS.has(pathname)) {
|
|
80
|
+
return ontologyUrl === undefined
|
|
81
|
+
? NextResponse.next()
|
|
82
|
+
: NextResponse.rewrite(new URL(`${pathname}${search}`, ontologyUrl));
|
|
83
|
+
}
|
|
84
|
+
// The issuer is the root origin, so RFC 8414 does not define an AS document suffixed by /mcp.
|
|
85
|
+
// This service also does not publish OpenID Connect discovery metadata.
|
|
86
|
+
return NextResponse.json({ error: "not_found" }, { status: 404 });
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
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.
|
|
92
|
+
*/
|
|
93
|
+
async function sessionUnavailable(context, request, policy) {
|
|
94
|
+
if (isApiRequest(request) || hasChannelApps(context.config.channels)) {
|
|
95
|
+
return NextResponse.json({ error: "session_refresh_unavailable" }, { status: 503, headers: { "cache-control": "no-store", "retry-after": "5" } });
|
|
96
|
+
}
|
|
97
|
+
return startSignIn(context, request, policy, false);
|
|
98
|
+
}
|
|
99
|
+
async function startSignIn(context, request, policy, clearSession) {
|
|
100
|
+
if (isApiRequest(request)) {
|
|
101
|
+
const response = unauthenticatedResponse();
|
|
102
|
+
if (clearSession) {
|
|
103
|
+
clearSessionCookies(response, policy);
|
|
104
|
+
}
|
|
105
|
+
return response;
|
|
106
|
+
}
|
|
107
|
+
const returnTo = safeReturnTo(`${request.nextUrl.pathname}${request.nextUrl.search}`);
|
|
108
|
+
return signInRedirect({ config: context.config, request, returnTo, clearSession });
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Lets the current request render with the refreshed session. Response cookies set
|
|
112
|
+
* through `NextResponse.cookies` are merged into `cookies()` by Next.js, and the
|
|
113
|
+
* rewritten `Cookie` request header covers Route Handlers that read `request.cookies`.
|
|
114
|
+
*/
|
|
115
|
+
function continueWithSession(request, policy, tokens) {
|
|
116
|
+
const requestHeaders = new Headers(request.headers);
|
|
117
|
+
requestHeaders.set("cookie", replaceRequestCookies(request.headers.get("cookie"), {
|
|
118
|
+
[policy.names.session]: tokens.accessToken,
|
|
119
|
+
[policy.names.refresh]: tokens.refreshToken,
|
|
120
|
+
}));
|
|
121
|
+
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
|
122
|
+
setSessionCookies(response, policy, tokens);
|
|
123
|
+
return response;
|
|
124
|
+
}
|
|
125
|
+
export function normalizePublicPath(path) {
|
|
126
|
+
const withLeadingSlash = path.startsWith("/") ? path : `/${path}`;
|
|
127
|
+
const trimmed = trimTrailingSlashes(withLeadingSlash);
|
|
128
|
+
return trimmed === "" ? "/" : trimmed;
|
|
129
|
+
}
|
|
130
|
+
export function matchesPathPrefix(pathname, prefix) {
|
|
131
|
+
if (prefix === "/") {
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
return pathname === prefix || pathname.startsWith(`${prefix}/`);
|
|
135
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time an app Route Handler until it returns response headers. Streaming/rendering after that
|
|
3
|
+
* point is excluded. Logs contain no URL, cookies, body or error text. Server Components need
|
|
4
|
+
* framework instrumentation for complete page timing; a Next.js proxy only times authentication.
|
|
5
|
+
*/
|
|
6
|
+
export declare function withRequestTiming<R extends Request, A extends unknown[]>(handler: (request: R, ...args: A) => Promise<Response>): (request: R, ...args: A) => Promise<Response>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { unstable_rethrow } from "next/navigation";
|
|
2
|
+
import { getRedirectStatusCodeFromError } from "next/dist/client/components/redirect.js";
|
|
3
|
+
import { isRedirectError } from "next/dist/client/components/redirect-error.js";
|
|
4
|
+
import { getAccessFallbackHTTPStatus, isHTTPAccessFallbackError, } from "next/dist/client/components/http-access-fallback/http-access-fallback.js";
|
|
5
|
+
/**
|
|
6
|
+
* Time an app Route Handler until it returns response headers. Streaming/rendering after that
|
|
7
|
+
* point is excluded. Logs contain no URL, cookies, body or error text. Server Components need
|
|
8
|
+
* framework instrumentation for complete page timing; a Next.js proxy only times authentication.
|
|
9
|
+
*/
|
|
10
|
+
export function withRequestTiming(handler) {
|
|
11
|
+
return async (request, ...args) => {
|
|
12
|
+
const started = performance.now();
|
|
13
|
+
const requestId = crypto.randomUUID();
|
|
14
|
+
let status = 500;
|
|
15
|
+
let durationMs;
|
|
16
|
+
try {
|
|
17
|
+
const response = await handler(request, ...args);
|
|
18
|
+
const headers = new Headers(response.headers);
|
|
19
|
+
durationMs = Math.round((performance.now() - started) * 100) / 100;
|
|
20
|
+
status = response.status;
|
|
21
|
+
headers.append("server-timing", `app;dur=${durationMs}`);
|
|
22
|
+
headers.set("x-request-id", requestId);
|
|
23
|
+
return new Response(response.body, {
|
|
24
|
+
status,
|
|
25
|
+
statusText: response.statusText,
|
|
26
|
+
headers,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
try {
|
|
31
|
+
unstable_rethrow(error);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Next owns the eventual response for control-flow exceptions, outside this wrapper.
|
|
35
|
+
status = isRedirectError(error)
|
|
36
|
+
? getRedirectStatusCodeFromError(error)
|
|
37
|
+
: isHTTPAccessFallbackError(error)
|
|
38
|
+
? getAccessFallbackHTTPStatus(error)
|
|
39
|
+
: null;
|
|
40
|
+
}
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
console.info(JSON.stringify({
|
|
45
|
+
event: "app.request",
|
|
46
|
+
requestId,
|
|
47
|
+
method: request.method,
|
|
48
|
+
status,
|
|
49
|
+
durationMs: durationMs ?? Math.round((performance.now() - started) * 100) / 100,
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Reduces a return path to a same-origin relative path, falling back to `/`. */
|
|
2
|
+
export function safeReturnTo(value) {
|
|
3
|
+
if (typeof value !== "string" ||
|
|
4
|
+
!value.startsWith("/") ||
|
|
5
|
+
value.startsWith("//") ||
|
|
6
|
+
value.startsWith("/\\")) {
|
|
7
|
+
return "/";
|
|
8
|
+
}
|
|
9
|
+
if (/[\u0000-\u001f\u007f]/.test(value)) {
|
|
10
|
+
return "/";
|
|
11
|
+
}
|
|
12
|
+
const placeholderOrigin = "https://return-to.invalid";
|
|
13
|
+
let url;
|
|
14
|
+
try {
|
|
15
|
+
url = new URL(value, placeholderOrigin);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return "/";
|
|
19
|
+
}
|
|
20
|
+
if (url.origin !== placeholderOrigin) {
|
|
21
|
+
return "/";
|
|
22
|
+
}
|
|
23
|
+
return `${url.pathname}${url.search}${url.hash}`;
|
|
24
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AppContext } from "./context.js";
|
|
2
|
+
import type { AppUser } from "./tokens.js";
|
|
3
|
+
export interface ServerSession {
|
|
4
|
+
readonly getUser: () => Promise<AppUser | null>;
|
|
5
|
+
readonly requireUser: () => Promise<AppUser>;
|
|
6
|
+
readonly getAccessToken: () => Promise<string | null>;
|
|
7
|
+
readonly signOut: (options?: {
|
|
8
|
+
readonly redirectTo?: string;
|
|
9
|
+
}) => Promise<never>;
|
|
10
|
+
}
|
|
11
|
+
export declare function createServerSession(context: AppContext): ServerSession;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { cookies, headers } from "next/headers";
|
|
2
|
+
import { redirect } from "next/navigation";
|
|
3
|
+
import { cookiePolicyFor, isLoopbackHostname, safeReturnTo, sessionCookieCandidates, } from "./cookies.js";
|
|
4
|
+
import { SIGNED_OUT_PATH, SIGN_IN_PATH } from "./sign-in.js";
|
|
5
|
+
export function createServerSession(context) {
|
|
6
|
+
async function verifiedSession() {
|
|
7
|
+
const cookieStore = await cookies();
|
|
8
|
+
const headerStore = await headers();
|
|
9
|
+
const hostname = hostnameOf(headerStore.get("host"));
|
|
10
|
+
for (const name of sessionCookieCandidates(hostname, context.config.workspaceId)) {
|
|
11
|
+
const token = cookieStore.get(name)?.value;
|
|
12
|
+
if (token === undefined || token === "") {
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
const verification = await context.verifyToken(token);
|
|
16
|
+
if (verification.status === "valid") {
|
|
17
|
+
return verification;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
async function getUser() {
|
|
23
|
+
const session = await verifiedSession();
|
|
24
|
+
// Ontology roles come with Neon Auth; the legacy sign-in has none.
|
|
25
|
+
return session === null ? null : { ...session.user, roles: [], permissions: [] };
|
|
26
|
+
}
|
|
27
|
+
async function requireUser() {
|
|
28
|
+
const user = await getUser();
|
|
29
|
+
if (user === null) {
|
|
30
|
+
// `unauthorized()` needs the experimental `authInterrupts` flag, so restart sign-in instead.
|
|
31
|
+
redirect(SIGN_IN_PATH);
|
|
32
|
+
}
|
|
33
|
+
return user;
|
|
34
|
+
}
|
|
35
|
+
async function getAccessToken() {
|
|
36
|
+
const session = await verifiedSession();
|
|
37
|
+
return session?.token ?? null;
|
|
38
|
+
}
|
|
39
|
+
/** Revokes the app session and clears its cookies, as `POST /api/segmentstream/sign-out` does. */
|
|
40
|
+
async function signOut(options = {}) {
|
|
41
|
+
const cookieStore = await cookies();
|
|
42
|
+
const hostname = hostnameOf((await headers()).get("host"));
|
|
43
|
+
const policies = [
|
|
44
|
+
cookiePolicyFor(new URL("https://app.invalid"), context.config.workspaceId),
|
|
45
|
+
...(isLoopbackHostname(hostname)
|
|
46
|
+
? [cookiePolicyFor(new URL("http://localhost"), context.config.workspaceId)]
|
|
47
|
+
: []),
|
|
48
|
+
];
|
|
49
|
+
for (const policy of policies) {
|
|
50
|
+
const refreshToken = cookieStore.get(policy.names.refresh)?.value;
|
|
51
|
+
if (refreshToken !== undefined && refreshToken !== "") {
|
|
52
|
+
await context.revokeSession(refreshToken);
|
|
53
|
+
}
|
|
54
|
+
for (const name of [policy.names.session, policy.names.refresh]) {
|
|
55
|
+
// `__Host-` cookies are only replaced when the clearing cookie repeats Secure and Path=/.
|
|
56
|
+
cookieStore.set(name, "", {
|
|
57
|
+
httpOnly: true,
|
|
58
|
+
secure: policy.secure,
|
|
59
|
+
sameSite: "lax",
|
|
60
|
+
path: "/",
|
|
61
|
+
maxAge: 0,
|
|
62
|
+
expires: new Date(0),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
redirect(safeReturnTo(options.redirectTo ?? SIGNED_OUT_PATH));
|
|
67
|
+
}
|
|
68
|
+
return { getUser, requireUser, getAccessToken, signOut };
|
|
69
|
+
}
|
|
70
|
+
function hostnameOf(host) {
|
|
71
|
+
if (host === null || host === "") {
|
|
72
|
+
return "";
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
return new URL(`http://${host}`).hostname;
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return "";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "./config.js";
|
|
2
|
+
import type { SessionTokens } from "./cookies.js";
|
|
3
|
+
export type Fetch = typeof fetch;
|
|
4
|
+
export type TokenGrant = {
|
|
5
|
+
readonly grantType: "authorization_code";
|
|
6
|
+
readonly code: string;
|
|
7
|
+
readonly codeVerifier: string;
|
|
8
|
+
readonly redirectUri: string;
|
|
9
|
+
} | {
|
|
10
|
+
readonly grantType: "refresh_token";
|
|
11
|
+
readonly refreshToken: string;
|
|
12
|
+
};
|
|
13
|
+
export type TokenExchange = {
|
|
14
|
+
readonly status: "issued";
|
|
15
|
+
readonly tokens: SessionTokens;
|
|
16
|
+
} | {
|
|
17
|
+
readonly status: "rejected";
|
|
18
|
+
readonly httpStatus: number;
|
|
19
|
+
readonly code: string | null;
|
|
20
|
+
} | {
|
|
21
|
+
readonly status: "unavailable";
|
|
22
|
+
};
|
|
23
|
+
/** Calls the SegmentStream app-session endpoints from the app's server. */
|
|
24
|
+
export declare function createSessionClient(config: ResolvedConfig, fetchImplementation: Fetch): {
|
|
25
|
+
requestToken: (grant: TokenGrant) => Promise<TokenExchange>;
|
|
26
|
+
revoke: (refreshToken: string) => Promise<void>;
|
|
27
|
+
};
|
package/dist/sessions.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const REQUEST_TIMEOUT_MS = 10_000;
|
|
2
|
+
/** Calls the SegmentStream app-session endpoints from the app's server. */
|
|
3
|
+
export function createSessionClient(config, fetchImplementation) {
|
|
4
|
+
async function post(path, body) {
|
|
5
|
+
return fetchImplementation(`${config.baseUrl}${path}`, {
|
|
6
|
+
method: "POST",
|
|
7
|
+
headers: { "content-type": "application/json", accept: "application/json" },
|
|
8
|
+
body: JSON.stringify(body),
|
|
9
|
+
cache: "no-store",
|
|
10
|
+
redirect: "error",
|
|
11
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
async function requestToken(grant) {
|
|
15
|
+
let response;
|
|
16
|
+
try {
|
|
17
|
+
response = await post("/api/v1/app-sessions/token", grant);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return { status: "unavailable" };
|
|
21
|
+
}
|
|
22
|
+
const body = await readJson(response);
|
|
23
|
+
if (response.ok) {
|
|
24
|
+
const tokens = sessionTokensFrom(body);
|
|
25
|
+
return tokens === null ? { status: "unavailable" } : { status: "issued", tokens };
|
|
26
|
+
}
|
|
27
|
+
if (response.status >= 500 || response.status === 429) {
|
|
28
|
+
return { status: "unavailable" };
|
|
29
|
+
}
|
|
30
|
+
return { status: "rejected", httpStatus: response.status, code: errorCodeFrom(body) };
|
|
31
|
+
}
|
|
32
|
+
async function revoke(refreshToken) {
|
|
33
|
+
try {
|
|
34
|
+
const response = await post("/api/v1/app-sessions/revoke", { refreshToken });
|
|
35
|
+
await response.body?.cancel();
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Sign-out is best effort: the local cookies are cleared either way.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { requestToken, revoke };
|
|
42
|
+
}
|
|
43
|
+
async function readJson(response) {
|
|
44
|
+
try {
|
|
45
|
+
return await response.json();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function sessionTokensFrom(body) {
|
|
52
|
+
if (typeof body !== "object" || body === null) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const { accessToken, expiresIn, refreshToken } = body;
|
|
56
|
+
if (typeof accessToken !== "string" ||
|
|
57
|
+
typeof refreshToken !== "string" ||
|
|
58
|
+
typeof expiresIn !== "number") {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
if (accessToken === "" || refreshToken === "" || !Number.isFinite(expiresIn) || expiresIn <= 0) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return { accessToken, expiresIn, refreshToken };
|
|
65
|
+
}
|
|
66
|
+
function errorCodeFrom(body) {
|
|
67
|
+
if (typeof body !== "object" || body === null) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const { error } = body;
|
|
71
|
+
return typeof error === "string" ? error : null;
|
|
72
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import type { NextRequest } from "next/server";
|
|
3
|
+
import type { ResolvedConfig } from "./config.js";
|
|
4
|
+
export declare const SDK_ROUTE_PREFIX = "/api/segmentstream";
|
|
5
|
+
export declare const CALLBACK_PATH = "/api/segmentstream/callback";
|
|
6
|
+
export declare const SIGN_IN_PATH = "/api/segmentstream/sign-in";
|
|
7
|
+
export declare const SIGNED_OUT_PATH = "/api/segmentstream/signed-out";
|
|
8
|
+
/**
|
|
9
|
+
* Requests that cannot follow a cross-origin sign-in redirect: anything other than a
|
|
10
|
+
* top-level page navigation. They receive a 401 instead of a redirect.
|
|
11
|
+
*/
|
|
12
|
+
export declare function isApiRequest(request: NextRequest): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Machine-readable lifecycle signals let the workspace choose its recovery UI. No request is
|
|
15
|
+
* replayed and a permanently pinned page is never put into an automatic reload loop.
|
|
16
|
+
*/
|
|
17
|
+
export declare function revisionUnavailableResponse(_request: Request, cause?: unknown): NextResponse;
|
|
18
|
+
export declare function unauthenticatedResponse(): NextResponse;
|
|
19
|
+
export interface SignInRedirectInput {
|
|
20
|
+
readonly config: ResolvedConfig;
|
|
21
|
+
readonly request: NextRequest;
|
|
22
|
+
readonly returnTo: string;
|
|
23
|
+
readonly clearSession: boolean;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Starts the authorization-code flow with PKCE by redirecting to SegmentStream. Each
|
|
27
|
+
* sign-in keeps its flow in a cookie named after its `state`, so tabs that start
|
|
28
|
+
* signing in at the same time do not overwrite each other.
|
|
29
|
+
*/
|
|
30
|
+
export declare function signInRedirect(input: SignInRedirectInput): Promise<NextResponse>;
|
|
31
|
+
export interface AuthorizationUrlInput {
|
|
32
|
+
readonly config: ResolvedConfig;
|
|
33
|
+
readonly redirectUri: string;
|
|
34
|
+
readonly state: string;
|
|
35
|
+
readonly codeChallenge: string;
|
|
36
|
+
}
|
|
37
|
+
export declare function authorizationUrl(input: AuthorizationUrlInput): URL;
|
package/dist/sign-in.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { MAX_PENDING_AUTH_FLOWS, clearCookie, clearSessionCookies, cookiePolicyFor, pendingAuthFlowCookies, setAuthFlowCookie, } from "./cookies.js";
|
|
3
|
+
import { createCodeChallenge, randomToken } from "./pkce.js";
|
|
4
|
+
import { SegmentStreamApiError } from "./errors.js";
|
|
5
|
+
export const SDK_ROUTE_PREFIX = "/api/segmentstream";
|
|
6
|
+
export const CALLBACK_PATH = `${SDK_ROUTE_PREFIX}/callback`;
|
|
7
|
+
export const SIGN_IN_PATH = `${SDK_ROUTE_PREFIX}/sign-in`;
|
|
8
|
+
export const SIGNED_OUT_PATH = `${SDK_ROUTE_PREFIX}/signed-out`;
|
|
9
|
+
/** Longer return paths are dropped so several pending sign-ins still fit in request headers. */
|
|
10
|
+
const MAX_RETURN_TO_LENGTH = 1024;
|
|
11
|
+
/**
|
|
12
|
+
* Requests that cannot follow a cross-origin sign-in redirect: anything other than a
|
|
13
|
+
* top-level page navigation. They receive a 401 instead of a redirect.
|
|
14
|
+
*/
|
|
15
|
+
export function isApiRequest(request) {
|
|
16
|
+
if (request.method !== "GET") {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
if (request.nextUrl.pathname === "/api" || request.nextUrl.pathname.startsWith("/api/")) {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
// Next.js strips some Flight headers before Proxy runs; `_rsc` and Accept still identify those requests.
|
|
23
|
+
if (request.headers.get("purpose") === "prefetch" ||
|
|
24
|
+
request.headers.get("sec-purpose")?.includes("prefetch"))
|
|
25
|
+
return true;
|
|
26
|
+
const flightHeaders = ["rsc", "next-router-prefetch", "next-router-state-tree", "next-action"];
|
|
27
|
+
if (flightHeaders.some((header) => request.headers.has(header)) ||
|
|
28
|
+
request.nextUrl.searchParams.has("_rsc")) {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
return !(request.headers.get("accept") ?? "").includes("text/html");
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Machine-readable lifecycle signals let the workspace choose its recovery UI. No request is
|
|
35
|
+
* replayed and a permanently pinned page is never put into an automatic reload loop.
|
|
36
|
+
*/
|
|
37
|
+
export function revisionUnavailableResponse(_request, cause) {
|
|
38
|
+
const failure = cause instanceof SegmentStreamApiError
|
|
39
|
+
? cause
|
|
40
|
+
: new SegmentStreamApiError({
|
|
41
|
+
code: "browser_auth_configuration_unavailable",
|
|
42
|
+
status: 503,
|
|
43
|
+
detail: "browser_auth_configuration_unavailable",
|
|
44
|
+
retryable: true,
|
|
45
|
+
});
|
|
46
|
+
return NextResponse.json({ error: failure.code, retryable: failure.retryable }, {
|
|
47
|
+
status: failure.status,
|
|
48
|
+
headers: {
|
|
49
|
+
"cache-control": "no-store",
|
|
50
|
+
"x-segmentstream-error": failure.code,
|
|
51
|
+
...(failure.code === "deployment_switching" ? { "retry-after": "1" } : {}),
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
export function unauthenticatedResponse() {
|
|
56
|
+
return NextResponse.json({ error: "unauthenticated" }, { status: 401, headers: { "cache-control": "no-store" } });
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Starts the authorization-code flow with PKCE by redirecting to SegmentStream. Each
|
|
60
|
+
* sign-in keeps its flow in a cookie named after its `state`, so tabs that start
|
|
61
|
+
* signing in at the same time do not overwrite each other.
|
|
62
|
+
*/
|
|
63
|
+
export async function signInRedirect(input) {
|
|
64
|
+
const { config, request, clearSession } = input;
|
|
65
|
+
const policy = cookiePolicyFor(request.nextUrl, config.workspaceId);
|
|
66
|
+
const state = randomToken();
|
|
67
|
+
const codeVerifier = randomToken();
|
|
68
|
+
const codeChallenge = await createCodeChallenge(codeVerifier);
|
|
69
|
+
const redirectUri = `${request.nextUrl.origin}${CALLBACK_PATH}`;
|
|
70
|
+
const returnTo = input.returnTo.length <= MAX_RETURN_TO_LENGTH ? input.returnTo : "/";
|
|
71
|
+
const authorizeUrl = authorizationUrl({ config, redirectUri, state, codeChallenge });
|
|
72
|
+
const response = NextResponse.redirect(authorizeUrl, 307);
|
|
73
|
+
const pending = pendingAuthFlowCookies(policy, request.cookies.getAll().map((cookie) => cookie.name));
|
|
74
|
+
response.headers.set("cache-control", "no-store");
|
|
75
|
+
if (pending.length >= MAX_PENDING_AUTH_FLOWS) {
|
|
76
|
+
for (const name of pending) {
|
|
77
|
+
clearCookie(response, policy, name);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
setAuthFlowCookie(response, policy, { state, codeVerifier, redirectUri, returnTo });
|
|
81
|
+
if (clearSession) {
|
|
82
|
+
clearSessionCookies(response, policy);
|
|
83
|
+
}
|
|
84
|
+
return response;
|
|
85
|
+
}
|
|
86
|
+
export function authorizationUrl(input) {
|
|
87
|
+
const url = new URL(`${input.config.baseUrl}/auth/apps/authorize`);
|
|
88
|
+
url.searchParams.set("workspace_id", input.config.workspaceId);
|
|
89
|
+
url.searchParams.set("organization_id", input.config.organizationId);
|
|
90
|
+
url.searchParams.set("redirect_uri", input.redirectUri);
|
|
91
|
+
url.searchParams.set("state", input.state);
|
|
92
|
+
url.searchParams.set("code_challenge", input.codeChallenge);
|
|
93
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
94
|
+
return url;
|
|
95
|
+
}
|