@segmentstream-cloud/app 0.0.0-stage → 0.2.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.
@@ -0,0 +1,142 @@
1
+ import { base64UrlDecode, base64UrlEncode } from "./pkce.js";
2
+ export const REFRESH_COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
3
+ export const AUTH_FLOW_COOKIE_MAX_AGE_SECONDS = 10 * 60;
4
+ const SECURE_NAMES = {
5
+ session: "__Host-ss_app_session",
6
+ refresh: "__Host-ss_app_refresh",
7
+ authFlow: "__Host-ss_app_auth",
8
+ };
9
+ const LOOPBACK_NAMES = {
10
+ session: "ss_app_session",
11
+ refresh: "ss_app_refresh",
12
+ authFlow: "ss_app_auth",
13
+ };
14
+ export function isLoopbackHostname(hostname) {
15
+ const normalized = hostname.toLowerCase();
16
+ return (normalized === "localhost" ||
17
+ normalized.endsWith(".localhost") ||
18
+ normalized === "[::1]" ||
19
+ normalized === "::1" ||
20
+ /^127(?:\.\d{1,3}){3}$/.test(normalized));
21
+ }
22
+ /**
23
+ * Browsers reject `Secure` and `__Host-` cookies on plain-HTTP origins, except that
24
+ * local development on http://localhost should still work. Every other origin uses
25
+ * `__Host-` cookies, which cannot be set by sibling subdomains.
26
+ */
27
+ export function cookiePolicyFor(url) {
28
+ const insecureLoopback = url.protocol === "http:" && isLoopbackHostname(url.hostname);
29
+ return insecureLoopback
30
+ ? { secure: false, names: LOOPBACK_NAMES }
31
+ : { secure: true, names: SECURE_NAMES };
32
+ }
33
+ /** Candidate cookie names for code that only knows the request host (Server Components). */
34
+ export function sessionCookieCandidates(hostname) {
35
+ return isLoopbackHostname(hostname)
36
+ ? [SECURE_NAMES.session, LOOPBACK_NAMES.session]
37
+ : [SECURE_NAMES.session];
38
+ }
39
+ function cookieAttributes(policy, maxAge) {
40
+ return {
41
+ httpOnly: true,
42
+ secure: policy.secure,
43
+ sameSite: "lax",
44
+ path: "/",
45
+ maxAge,
46
+ };
47
+ }
48
+ export function setSessionCookies(response, policy, tokens) {
49
+ const accessMaxAge = Math.max(1, Math.floor(tokens.expiresIn));
50
+ response.cookies.set(policy.names.session, tokens.accessToken, cookieAttributes(policy, accessMaxAge));
51
+ response.cookies.set(policy.names.refresh, tokens.refreshToken, cookieAttributes(policy, REFRESH_COOKIE_MAX_AGE_SECONDS));
52
+ }
53
+ export function clearSessionCookies(response, policy) {
54
+ clearCookie(response, policy, policy.names.session);
55
+ clearCookie(response, policy, policy.names.refresh);
56
+ }
57
+ export function setAuthFlowCookie(response, policy, flow) {
58
+ response.cookies.set(policy.names.authFlow, encodeAuthFlowState(flow), cookieAttributes(policy, AUTH_FLOW_COOKIE_MAX_AGE_SECONDS));
59
+ }
60
+ export function clearAuthFlowCookie(response, policy) {
61
+ clearCookie(response, policy, policy.names.authFlow);
62
+ }
63
+ // `__Host-` cookies are only replaced when the clearing cookie repeats Secure and Path=/.
64
+ function clearCookie(response, policy, name) {
65
+ response.cookies.set(name, "", { ...cookieAttributes(policy, 0), expires: new Date(0) });
66
+ }
67
+ export function encodeAuthFlowState(flow) {
68
+ return base64UrlEncode(new TextEncoder().encode(JSON.stringify(flow)));
69
+ }
70
+ export function decodeAuthFlowState(value) {
71
+ if (value === undefined || value === "") {
72
+ return null;
73
+ }
74
+ let candidate;
75
+ try {
76
+ candidate = JSON.parse(new TextDecoder().decode(base64UrlDecode(value)));
77
+ }
78
+ catch {
79
+ return null;
80
+ }
81
+ if (!isAuthFlowState(candidate)) {
82
+ return null;
83
+ }
84
+ return {
85
+ state: candidate.state,
86
+ codeVerifier: candidate.codeVerifier,
87
+ redirectUri: candidate.redirectUri,
88
+ returnTo: safeReturnTo(candidate.returnTo),
89
+ };
90
+ }
91
+ function isAuthFlowState(value) {
92
+ if (typeof value !== "object" || value === null) {
93
+ return false;
94
+ }
95
+ const record = value;
96
+ return (typeof record.state === "string" &&
97
+ typeof record.codeVerifier === "string" &&
98
+ typeof record.redirectUri === "string" &&
99
+ typeof record.returnTo === "string");
100
+ }
101
+ /** Reduces a return path to a same-origin relative path, falling back to `/`. */
102
+ export function safeReturnTo(value) {
103
+ if (typeof value !== "string" ||
104
+ !value.startsWith("/") ||
105
+ value.startsWith("//") ||
106
+ value.startsWith("/\\")) {
107
+ return "/";
108
+ }
109
+ if (/[\u0000-\u001f\u007f]/.test(value)) {
110
+ return "/";
111
+ }
112
+ const placeholderOrigin = "https://return-to.invalid";
113
+ let url;
114
+ try {
115
+ url = new URL(value, placeholderOrigin);
116
+ }
117
+ catch {
118
+ return "/";
119
+ }
120
+ if (url.origin !== placeholderOrigin) {
121
+ return "/";
122
+ }
123
+ return `${url.pathname}${url.search}${url.hash}`;
124
+ }
125
+ /**
126
+ * Replaces the named cookies in a raw `Cookie` request header while preserving every
127
+ * other cookie byte for byte.
128
+ */
129
+ export function replaceRequestCookies(cookieHeader, replacements) {
130
+ const replacedNames = new Set(Object.keys(replacements));
131
+ const preserved = (cookieHeader ?? "")
132
+ .split(";")
133
+ .map((pair) => pair.trim())
134
+ .filter((pair) => pair !== "")
135
+ .filter((pair) => !replacedNames.has(cookiePairName(pair)));
136
+ const replaced = Object.entries(replacements).map(([name, value]) => `${name}=${value}`);
137
+ return [...preserved, ...replaced].join("; ");
138
+ }
139
+ function cookiePairName(pair) {
140
+ const separator = pair.indexOf("=");
141
+ return (separator === -1 ? pair : pair.slice(0, separator)).trim();
142
+ }
package/dist/data.d.ts ADDED
@@ -0,0 +1,138 @@
1
+ import type { ResolvedConfig } from "./config.js";
2
+ import type { QueryParameters } from "./parameters.js";
3
+ import type { Fetch } from "./sessions.js";
4
+ /** A failed SegmentStream API request; `code` is the API error code. */
5
+ export declare class SegmentStreamApiError extends Error {
6
+ readonly code: string;
7
+ readonly status: number;
8
+ readonly detail: string;
9
+ readonly requestId: string | undefined;
10
+ readonly retryable: boolean;
11
+ /** The warehouse query this error concerns, when one was started. */
12
+ readonly queryId: string | undefined;
13
+ constructor(input: {
14
+ readonly code: string;
15
+ readonly status: number;
16
+ readonly detail: string;
17
+ readonly requestId?: string | undefined;
18
+ readonly retryable?: boolean;
19
+ readonly queryId?: string | undefined;
20
+ });
21
+ }
22
+ export type WarehouseValue = string | number | boolean | null;
23
+ export type WarehouseRow = Record<string, WarehouseValue>;
24
+ export interface WarehouseColumn {
25
+ readonly name: string;
26
+ readonly type: string;
27
+ readonly mode: string;
28
+ readonly description: string;
29
+ }
30
+ /** How much of a query result to wait for and collect. */
31
+ export interface WarehouseReadOptions {
32
+ /** Rows to collect across pages before stopping. Defaults to 10,000. */
33
+ readonly maxRows?: number;
34
+ /** Rows per API page, 1–1,000. Defaults to the smaller of `maxRows` and 1,000. */
35
+ readonly pageSize?: number;
36
+ /** How long to wait for a running query. Defaults to 60 seconds. */
37
+ readonly timeoutMs?: number;
38
+ readonly signal?: AbortSignal;
39
+ }
40
+ export interface WarehouseQueryOptions extends WarehouseReadOptions {
41
+ /** Values for named `@parameters` in the SQL. Never interpolate values into the SQL text. */
42
+ readonly params?: QueryParameters;
43
+ /** Lowers the workspace's per-query byte ceiling for this query. */
44
+ readonly maximumBytesBilled?: number | bigint | string;
45
+ }
46
+ export interface WarehouseQueryResult<Row = WarehouseRow> {
47
+ readonly queryId: string | null;
48
+ readonly columns: readonly WarehouseColumn[];
49
+ readonly rows: Row[];
50
+ /** Rows in the full result, which may exceed `rows` when `truncated`. */
51
+ readonly totalRows: number | null;
52
+ /** True when more rows exist than `maxRows` allowed. */
53
+ readonly truncated: boolean;
54
+ readonly bytesProcessed: string | null;
55
+ readonly bytesBilled: string | null;
56
+ readonly cacheHit: boolean | null;
57
+ }
58
+ export interface WarehouseDryRun {
59
+ readonly columns: readonly WarehouseColumn[];
60
+ readonly bytesProcessed: string | null;
61
+ readonly maximumBytesBilled: string;
62
+ }
63
+ export interface SemanticQueryRequest {
64
+ readonly dimensions: readonly string[];
65
+ readonly measures: readonly string[];
66
+ readonly dateRange: {
67
+ readonly from: string;
68
+ readonly to: string;
69
+ };
70
+ readonly granularity?: "day" | "week" | "month";
71
+ readonly filters?: ReadonlyArray<{
72
+ readonly dimension: string;
73
+ readonly operator: "equals" | "in" | "isNull" | "isNotNull";
74
+ readonly value?: string | number | boolean;
75
+ readonly values?: ReadonlyArray<string | number | boolean>;
76
+ }>;
77
+ readonly orderBy?: ReadonlyArray<{
78
+ readonly measure?: string;
79
+ readonly dimension?: string;
80
+ readonly direction: "asc" | "desc";
81
+ }>;
82
+ readonly limit?: number;
83
+ }
84
+ export interface SemanticField {
85
+ readonly name: string;
86
+ readonly label: string;
87
+ readonly type: string;
88
+ readonly unit?: string;
89
+ readonly format?: string;
90
+ }
91
+ export interface SemanticQueryResult<Row = Record<string, string | boolean | null>> {
92
+ readonly deploymentId: string | null;
93
+ readonly definitionDigest: string;
94
+ readonly fields: readonly SemanticField[];
95
+ readonly rows: Row[];
96
+ readonly truncated: boolean;
97
+ readonly execution: {
98
+ readonly elapsedMs: number;
99
+ readonly bytesProcessed?: string;
100
+ readonly bytesBilled?: string;
101
+ };
102
+ }
103
+ export interface WarehouseClient {
104
+ /** Runs read-only SQL in the workspace warehouse as the signed-in user's app session. */
105
+ readonly query: <Row = WarehouseRow>(sql: string, options?: WarehouseQueryOptions) => Promise<WarehouseQueryResult<Row>>;
106
+ /**
107
+ * Resumes a query by ID from a later request. Queries keep running in the warehouse for
108
+ * up to an hour after `query()` stops waiting, and each request uses a fresh app token.
109
+ */
110
+ readonly result: <Row = WarehouseRow>(queryId: string, options?: WarehouseReadOptions) => Promise<WarehouseQueryResult<Row>>;
111
+ /** Validates SQL and estimates bytes processed without running it. */
112
+ readonly dryRun: (sql: string, options?: Pick<WarehouseQueryOptions, "params" | "maximumBytesBilled" | "signal">) => Promise<WarehouseDryRun>;
113
+ }
114
+ export interface SemanticClient {
115
+ readonly list: () => Promise<{
116
+ readonly deploymentId: string | null;
117
+ readonly models: readonly unknown[];
118
+ }>;
119
+ readonly get: (model: string) => Promise<{
120
+ readonly deploymentId: string | null;
121
+ readonly definitionDigest: string;
122
+ readonly model: unknown;
123
+ }>;
124
+ readonly query: <Row = Record<string, string | boolean | null>>(model: string, request: SemanticQueryRequest) => Promise<SemanticQueryResult<Row>>;
125
+ }
126
+ export interface DataClientDependencies {
127
+ readonly config: ResolvedConfig;
128
+ readonly fetch: Fetch;
129
+ readonly getAccessToken: () => Promise<string | null>;
130
+ readonly sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
131
+ readonly now?: () => number;
132
+ }
133
+ /** Server-side SegmentStream API access as the signed-in member. */
134
+ export declare function createDataClient(dependencies: DataClientDependencies): {
135
+ fetch: (path: string, init?: RequestInit) => Promise<Response>;
136
+ warehouse: WarehouseClient;
137
+ semantic: SemanticClient;
138
+ };
package/dist/data.js ADDED
@@ -0,0 +1,225 @@
1
+ import { encodeParameters } from "./parameters.js";
2
+ /** A failed SegmentStream API request; `code` is the API error code. */
3
+ export class SegmentStreamApiError extends Error {
4
+ code;
5
+ status;
6
+ detail;
7
+ requestId;
8
+ retryable;
9
+ /** The warehouse query this error concerns, when one was started. */
10
+ queryId;
11
+ constructor(input) {
12
+ super(`${input.detail} (${input.code})`);
13
+ this.name = "SegmentStreamApiError";
14
+ this.code = input.code;
15
+ this.status = input.status;
16
+ this.detail = input.detail;
17
+ this.requestId = input.requestId;
18
+ this.retryable = input.retryable ?? false;
19
+ this.queryId = input.queryId;
20
+ }
21
+ }
22
+ const REQUEST_TIMEOUT_MS = 45_000;
23
+ const DEFAULT_MAX_ROWS = 10_000;
24
+ const MAXIMUM_PAGE_SIZE = 1000;
25
+ const DEFAULT_TIMEOUT_MS = 60_000;
26
+ const FIRST_POLL_MS = 500;
27
+ const LONGEST_POLL_MS = 2000;
28
+ /** Server-side SegmentStream API access as the signed-in member. */
29
+ export function createDataClient(dependencies) {
30
+ const { config } = dependencies;
31
+ const workspace = `/api/v1/workspaces/${encodeURIComponent(config.workspaceId)}`;
32
+ const sleep = dependencies.sleep ?? defaultSleep;
33
+ const now = dependencies.now ?? Date.now;
34
+ /** Calls any SegmentStream API path as the signed-in member; the token never leaves the server. */
35
+ async function apiFetch(path, init = {}) {
36
+ if (!path.startsWith("/api/")) {
37
+ throw new TypeError('SegmentStream API paths start with "/api/", for example "/api/v1/me".');
38
+ }
39
+ const token = await dependencies.getAccessToken();
40
+ if (token === null) {
41
+ throw new SegmentStreamApiError({
42
+ code: "app_session_missing",
43
+ status: 401,
44
+ detail: "No signed-in SegmentStream session for this request; call SegmentStream from Server Components, Route Handlers, or Server Functions behind the SegmentStream proxy.",
45
+ });
46
+ }
47
+ const headers = new Headers(init.headers);
48
+ const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
49
+ headers.set("authorization", `Bearer ${token}`);
50
+ if (!headers.has("accept")) {
51
+ headers.set("accept", "application/json");
52
+ }
53
+ try {
54
+ return await dependencies.fetch(`${config.baseUrl}${path}`, {
55
+ ...init,
56
+ headers,
57
+ cache: "no-store",
58
+ redirect: "error",
59
+ signal: init.signal ? AbortSignal.any([init.signal, timeout]) : timeout,
60
+ });
61
+ }
62
+ catch (error) {
63
+ if (init.signal?.aborted) {
64
+ throw error;
65
+ }
66
+ throw new SegmentStreamApiError({
67
+ code: "segmentstream_unreachable",
68
+ status: 503,
69
+ detail: "SegmentStream could not be reached.",
70
+ retryable: true,
71
+ });
72
+ }
73
+ }
74
+ async function call(method, path, body, signal) {
75
+ const response = await apiFetch(path, {
76
+ method,
77
+ ...(body === undefined
78
+ ? {}
79
+ : { body: JSON.stringify(body), headers: { "content-type": "application/json" } }),
80
+ ...(signal ? { signal } : {}),
81
+ });
82
+ const payload = await readJson(response);
83
+ if (!response.ok) {
84
+ throw errorFrom(response.status, payload);
85
+ }
86
+ return payload;
87
+ }
88
+ function limits(options) {
89
+ const maxRows = positiveInteger(options.maxRows ?? DEFAULT_MAX_ROWS, "maxRows");
90
+ const pageSize = positiveInteger(options.pageSize ?? Math.min(maxRows, MAXIMUM_PAGE_SIZE), "pageSize");
91
+ return { maxRows, pageSize, deadline: now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS) };
92
+ }
93
+ /** Waits for a running query, then follows pages up to `maxRows`. */
94
+ async function collect(first, { maxRows, pageSize, deadline }, signal) {
95
+ let page = first;
96
+ const queryId = page.id ?? null;
97
+ let wait = FIRST_POLL_MS;
98
+ while (page.state === "running") {
99
+ if (queryId === null || now() + wait > deadline) {
100
+ throw new SegmentStreamApiError({
101
+ code: "query_timeout",
102
+ status: 504,
103
+ detail: "The query is still running; resume it from a later request with warehouse.result(queryId).",
104
+ retryable: true,
105
+ queryId: queryId ?? undefined,
106
+ });
107
+ }
108
+ await sleep(wait, signal);
109
+ wait = Math.min(wait * 2, LONGEST_POLL_MS);
110
+ page = (await read(queryId, pageSize, undefined, signal)).query;
111
+ }
112
+ const head = page;
113
+ const rows = [...page.rows];
114
+ while (page.nextPageToken !== undefined && rows.length < maxRows && queryId !== null) {
115
+ page = (await read(queryId, pageSize, page.nextPageToken, signal)).query;
116
+ rows.push(...page.rows);
117
+ }
118
+ return {
119
+ queryId,
120
+ columns: head.columns,
121
+ rows: rows.slice(0, maxRows),
122
+ totalRows: head.totalRows === undefined ? null : Number(head.totalRows),
123
+ truncated: rows.length > maxRows || page.nextPageToken !== undefined,
124
+ bytesProcessed: head.bytesProcessed ?? null,
125
+ bytesBilled: head.bytesBilled ?? null,
126
+ cacheHit: head.cacheHit ?? null,
127
+ };
128
+ }
129
+ async function query(sql, options = {}) {
130
+ const bounds = limits(options);
131
+ const { query: page } = await call("POST", `${workspace}/warehouse/queries`, {
132
+ sql,
133
+ parameters: encodeParameters(options.params),
134
+ ...bytesLimit(options.maximumBytesBilled),
135
+ pageSize: bounds.pageSize,
136
+ }, options.signal);
137
+ return collect(page, bounds, options.signal);
138
+ }
139
+ async function result(queryId, options = {}) {
140
+ const bounds = limits(options);
141
+ const { query: page } = await read(queryId, bounds.pageSize, undefined, options.signal);
142
+ return collect(page, bounds, options.signal);
143
+ }
144
+ function read(queryId, pageSize, pageToken, signal) {
145
+ const search = new URLSearchParams({ pageSize: String(pageSize) });
146
+ if (pageToken !== undefined) {
147
+ search.set("pageToken", pageToken);
148
+ }
149
+ return call("GET", `${workspace}/warehouse/queries/${encodeURIComponent(queryId)}?${search.toString()}`, undefined, signal);
150
+ }
151
+ const warehouse = {
152
+ query,
153
+ result,
154
+ dryRun: async (sql, options = {}) => {
155
+ const { query: page } = await call("POST", `${workspace}/warehouse/queries`, {
156
+ sql,
157
+ parameters: encodeParameters(options.params),
158
+ ...bytesLimit(options.maximumBytesBilled),
159
+ dryRun: true,
160
+ }, options.signal);
161
+ return {
162
+ columns: page.columns,
163
+ bytesProcessed: page.bytesProcessed ?? null,
164
+ maximumBytesBilled: page.maximumBytesBilled,
165
+ };
166
+ },
167
+ };
168
+ const semantic = {
169
+ list: () => call("GET", `${workspace}/semantic-models`),
170
+ get: (model) => call("GET", `${workspace}/semantic-models/${encodeURIComponent(model)}`),
171
+ query: (model, request) => call("POST", `${workspace}/semantic-models/${encodeURIComponent(model)}/query`, request),
172
+ };
173
+ return { fetch: apiFetch, warehouse, semantic };
174
+ }
175
+ function bytesLimit(value) {
176
+ if (value === undefined) {
177
+ return {};
178
+ }
179
+ const text = String(value);
180
+ if (!/^[1-9][0-9]*$/.test(text)) {
181
+ throw new TypeError("maximumBytesBilled must be a positive whole number of bytes.");
182
+ }
183
+ return { maximumBytesBilled: text };
184
+ }
185
+ function positiveInteger(value, name) {
186
+ if (!Number.isInteger(value) || value < 1) {
187
+ throw new TypeError(`${name} must be a positive integer.`);
188
+ }
189
+ return value;
190
+ }
191
+ async function readJson(response) {
192
+ try {
193
+ return await response.json();
194
+ }
195
+ catch {
196
+ return null;
197
+ }
198
+ }
199
+ function errorFrom(status, payload) {
200
+ const body = typeof payload === "object" && payload !== null ? payload : {};
201
+ return new SegmentStreamApiError({
202
+ code: typeof body.error === "string" ? body.error : `http_${status}`,
203
+ status,
204
+ detail: typeof body.detail === "string" ? body.detail : "SegmentStream rejected the request.",
205
+ requestId: typeof body.requestId === "string" ? body.requestId : undefined,
206
+ retryable: typeof body.retryable === "boolean" ? body.retryable : status >= 500,
207
+ });
208
+ }
209
+ function defaultSleep(ms, signal) {
210
+ return new Promise((resolve, reject) => {
211
+ if (signal?.aborted) {
212
+ reject(signal.reason);
213
+ return;
214
+ }
215
+ const timer = setTimeout(() => {
216
+ signal?.removeEventListener("abort", abort);
217
+ resolve();
218
+ }, ms);
219
+ const abort = () => {
220
+ clearTimeout(timer);
221
+ reject(signal?.reason);
222
+ };
223
+ signal?.addEventListener("abort", abort, { once: true });
224
+ });
225
+ }
@@ -0,0 +1,8 @@
1
+ import type { NextRequest } from "next/server";
2
+ import type { AppContext } from "./context.js";
3
+ export type RouteHandler = (request: NextRequest) => Promise<Response>;
4
+ export interface SegmentStreamRouteHandlers {
5
+ readonly GET: RouteHandler;
6
+ readonly POST: RouteHandler;
7
+ }
8
+ export declare function createHandlers(context: AppContext): SegmentStreamRouteHandlers;
@@ -0,0 +1,133 @@
1
+ import { NextResponse } from "next/server";
2
+ import { clearAuthFlowCookie, clearSessionCookies, cookiePolicyFor, decodeAuthFlowState, safeReturnTo, setSessionCookies, } from "./cookies.js";
3
+ import { constantTimeEqual } from "./pkce.js";
4
+ import { CALLBACK_PATH, SIGNED_OUT_PATH, SIGN_IN_PATH, SDK_ROUTE_PREFIX, signInRedirect, } from "./sign-in.js";
5
+ export function createHandlers(context) {
6
+ async function GET(request) {
7
+ switch (request.nextUrl.pathname) {
8
+ case CALLBACK_PATH:
9
+ return handleCallback(context, request);
10
+ case SIGN_IN_PATH:
11
+ return signInRedirect({
12
+ config: context.config,
13
+ request,
14
+ returnTo: safeReturnTo(request.nextUrl.searchParams.get("returnTo")),
15
+ clearSession: false,
16
+ });
17
+ case SIGNED_OUT_PATH:
18
+ return htmlResponse(200, "You're signed out", "You have signed out of this app.", "Sign in again");
19
+ default:
20
+ return notFound();
21
+ }
22
+ }
23
+ async function POST(request) {
24
+ if (request.nextUrl.pathname === `${SDK_ROUTE_PREFIX}/sign-out`) {
25
+ return handleSignOut(context, request);
26
+ }
27
+ return notFound();
28
+ }
29
+ return { GET, POST };
30
+ }
31
+ async function handleCallback(context, request) {
32
+ const policy = cookiePolicyFor(request.nextUrl);
33
+ const flow = decodeAuthFlowState(request.cookies.get(policy.names.authFlow)?.value);
34
+ const code = request.nextUrl.searchParams.get("code");
35
+ const state = request.nextUrl.searchParams.get("state");
36
+ if (request.nextUrl.searchParams.get("error") === "access_denied") {
37
+ return callbackFailure(policy, 403, "You don't have access to this app.");
38
+ }
39
+ if (flow === null ||
40
+ code === null ||
41
+ code === "" ||
42
+ state === null ||
43
+ !constantTimeEqual(state, flow.state)) {
44
+ return callbackFailure(policy, 400, "This sign-in link is invalid or has expired.");
45
+ }
46
+ const exchange = await context.requestToken({
47
+ grantType: "authorization_code",
48
+ code,
49
+ codeVerifier: flow.codeVerifier,
50
+ redirectUri: flow.redirectUri,
51
+ });
52
+ if (exchange.status === "unavailable") {
53
+ return callbackFailure(policy, 503, "Sign-in is temporarily unavailable. Try again in a moment.");
54
+ }
55
+ if (exchange.status === "rejected") {
56
+ const accessRevoked = exchange.httpStatus === 403 || exchange.code === "access_revoked";
57
+ return accessRevoked
58
+ ? callbackFailure(policy, 403, "You don't have access to this app.")
59
+ : callbackFailure(policy, 400, "Sign-in could not be completed. Try again.");
60
+ }
61
+ // A token this app would reject must not become a session, or sign-in would loop.
62
+ const verification = await context.verifyToken(exchange.tokens.accessToken);
63
+ if (verification.status === "unavailable") {
64
+ return callbackFailure(policy, 503, "Sign-in is temporarily unavailable. Try again in a moment.");
65
+ }
66
+ if (verification.status !== "valid") {
67
+ return callbackFailure(policy, 400, "Sign-in returned a session for a different app.");
68
+ }
69
+ const response = NextResponse.redirect(new URL(flow.returnTo, request.nextUrl.origin), 303);
70
+ response.headers.set("cache-control", "no-store");
71
+ clearAuthFlowCookie(response, policy);
72
+ setSessionCookies(response, policy, exchange.tokens);
73
+ return response;
74
+ }
75
+ async function handleSignOut(context, request) {
76
+ const origin = request.headers.get("origin");
77
+ if (origin !== null && origin !== request.nextUrl.origin) {
78
+ return htmlResponse(403, "Sign-out refused", "Sign out from this app's own pages.", "Return to the app");
79
+ }
80
+ const policy = cookiePolicyFor(request.nextUrl);
81
+ const refreshToken = request.cookies.get(policy.names.refresh)?.value;
82
+ if (refreshToken !== undefined && refreshToken !== "") {
83
+ await context.revokeSession(refreshToken);
84
+ }
85
+ const response = NextResponse.redirect(new URL(SIGNED_OUT_PATH, request.nextUrl.origin), 303);
86
+ response.headers.set("cache-control", "no-store");
87
+ clearSessionCookies(response, policy);
88
+ clearAuthFlowCookie(response, policy);
89
+ return response;
90
+ }
91
+ function callbackFailure(policy, status, message) {
92
+ const response = htmlResponse(status, "Sign-in failed", message, "Try again");
93
+ clearAuthFlowCookie(response, policy);
94
+ return response;
95
+ }
96
+ function notFound() {
97
+ return NextResponse.json({ error: "not_found" }, { status: 404, headers: { "cache-control": "no-store" } });
98
+ }
99
+ function htmlResponse(status, title, message, linkLabel) {
100
+ const body = [
101
+ "<!doctype html>",
102
+ '<html lang="en">',
103
+ "<head>",
104
+ '<meta charset="utf-8">',
105
+ '<meta name="viewport" content="width=device-width, initial-scale=1">',
106
+ '<meta name="robots" content="noindex">',
107
+ `<title>${escapeHtml(title)}</title>`,
108
+ "<style>body{font-family:system-ui,sans-serif;max-width:32rem;margin:15vh auto;padding:0 1rem;}</style>",
109
+ "</head>",
110
+ "<body>",
111
+ `<h1>${escapeHtml(title)}</h1>`,
112
+ `<p>${escapeHtml(message)}</p>`,
113
+ `<p><a href="/">${escapeHtml(linkLabel)}</a></p>`,
114
+ "</body>",
115
+ "</html>",
116
+ ].join("\n");
117
+ return new NextResponse(body, {
118
+ status,
119
+ headers: {
120
+ "content-type": "text/html; charset=utf-8",
121
+ "cache-control": "no-store",
122
+ "x-content-type-options": "nosniff",
123
+ },
124
+ });
125
+ }
126
+ function escapeHtml(value) {
127
+ return value
128
+ .replace(/&/g, "&amp;")
129
+ .replace(/</g, "&lt;")
130
+ .replace(/>/g, "&gt;")
131
+ .replace(/"/g, "&quot;")
132
+ .replace(/'/g, "&#39;");
133
+ }
@@ -0,0 +1,22 @@
1
+ import type { SegmentStreamApp } from "./app.js";
2
+ import type { SegmentStreamAppConfig } from "./config.js";
3
+ /**
4
+ * Creates the SegmentStream sign-in integration for a Next.js workspace app.
5
+ *
6
+ * ```ts
7
+ * export const segmentstream = createSegmentStreamApp({
8
+ * workspaceId: "wsp_...",
9
+ * organizationId: "org_...",
10
+ * });
11
+ * ```
12
+ */
13
+ export declare function createSegmentStreamApp(config: SegmentStreamAppConfig): SegmentStreamApp;
14
+ export { SegmentStreamApiError } from "./data.js";
15
+ export { sql } from "./parameters.js";
16
+ export type { SegmentStreamApp } from "./app.js";
17
+ export type { SemanticClient, SemanticField, SemanticQueryRequest, SemanticQueryResult, WarehouseClient, WarehouseColumn, WarehouseDryRun, WarehouseQueryOptions, WarehouseReadOptions, WarehouseQueryResult, WarehouseRow, WarehouseValue, } from "./data.js";
18
+ export type { ParameterType, QueryParameterValue, QueryParameters, TypedParameter, } from "./parameters.js";
19
+ export type { SegmentStreamAppConfig } from "./config.js";
20
+ export type { SegmentStreamRouteHandlers, RouteHandler } from "./handlers.js";
21
+ export type { ProxyOptions, SegmentStreamProxy } from "./proxy.js";
22
+ export type { AppUser } from "./tokens.js";
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ import { createApp } from "./app.js";
2
+ /**
3
+ * Creates the SegmentStream sign-in integration for a Next.js workspace app.
4
+ *
5
+ * ```ts
6
+ * export const segmentstream = createSegmentStreamApp({
7
+ * workspaceId: "wsp_...",
8
+ * organizationId: "org_...",
9
+ * });
10
+ * ```
11
+ */
12
+ export function createSegmentStreamApp(config) {
13
+ return createApp(config);
14
+ }
15
+ export { SegmentStreamApiError } from "./data.js";
16
+ export { sql } from "./parameters.js";