@segmentstream-cloud/app 0.1.0 → 0.3.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.
Files changed (49) hide show
  1. package/README.md +230 -47
  2. package/dist/app.d.ts +42 -5
  3. package/dist/app.js +81 -7
  4. package/dist/config.d.ts +17 -1
  5. package/dist/config.js +29 -7
  6. package/dist/cookies.d.ts +16 -7
  7. package/dist/cookies.js +36 -47
  8. package/dist/data.d.ts +126 -0
  9. package/dist/data.js +206 -0
  10. package/dist/errors.d.ts +36 -0
  11. package/dist/errors.js +66 -0
  12. package/dist/handlers.js +43 -19
  13. package/dist/index.d.ts +21 -7
  14. package/dist/index.js +12 -5
  15. package/dist/me.d.ts +24 -0
  16. package/dist/me.js +33 -0
  17. package/dist/neon.d.ts +92 -0
  18. package/dist/neon.js +414 -0
  19. package/dist/ontology-transport.d.ts +23 -0
  20. package/dist/ontology-transport.js +95 -0
  21. package/dist/ontology.d.ts +217 -0
  22. package/dist/ontology.js +141 -0
  23. package/dist/parameters.d.ts +55 -0
  24. package/dist/parameters.js +118 -0
  25. package/dist/permissions.d.ts +17 -0
  26. package/dist/permissions.js +33 -0
  27. package/dist/proxy.d.ts +2 -0
  28. package/dist/proxy.js +29 -13
  29. package/dist/return-to.d.ts +2 -0
  30. package/dist/return-to.js +24 -0
  31. package/dist/server.d.ts +3 -0
  32. package/dist/server.js +35 -5
  33. package/dist/sign-in.d.ts +5 -1
  34. package/dist/sign-in.js +17 -4
  35. package/dist/tokens.d.ts +22 -3
  36. package/dist/ui/components.d.ts +27 -0
  37. package/dist/ui/components.js +63 -0
  38. package/dist/ui/hooks.d.ts +19 -0
  39. package/dist/ui/hooks.js +56 -0
  40. package/dist/ui/index.d.ts +10 -0
  41. package/dist/ui/index.js +8 -0
  42. package/dist/ui/navigation.d.ts +5 -0
  43. package/dist/ui/navigation.js +8 -0
  44. package/dist/ui/sign-in.d.ts +19 -0
  45. package/dist/ui/sign-in.js +198 -0
  46. package/dist/ui/store.d.ts +61 -0
  47. package/dist/ui/store.js +155 -0
  48. package/dist/ui/styles.css +249 -0
  49. package/package.json +24 -7
package/dist/cookies.js CHANGED
@@ -1,16 +1,12 @@
1
1
  import { base64UrlDecode, base64UrlEncode } from "./pkce.js";
2
+ import { safeReturnTo } from "./return-to.js";
3
+ export { safeReturnTo } from "./return-to.js";
2
4
  export const REFRESH_COOKIE_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
3
5
  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
- };
6
+ /** Sign-ins one browser can have in progress at once; one more abandons them all. */
7
+ export const MAX_PENDING_AUTH_FLOWS = 4;
8
+ const SECURE_PREFIX = "__Host-";
9
+ const AUTH_FLOW_STATE = /^[A-Za-z0-9_-]{1,128}$/;
14
10
  export function isLoopbackHostname(hostname) {
15
11
  const normalized = hostname.toLowerCase();
16
12
  return (normalized === "localhost" ||
@@ -23,18 +19,38 @@ export function isLoopbackHostname(hostname) {
23
19
  * Browsers reject `Secure` and `__Host-` cookies on plain-HTTP origins, except that
24
20
  * local development on http://localhost should still work. Every other origin uses
25
21
  * `__Host-` cookies, which cannot be set by sibling subdomains.
22
+ *
23
+ * Browsers share cookies between ports, so apps on `localhost:3000` and `localhost:3001`
24
+ * see one cookie jar. Names carry the workspace id to keep each app's session its own.
26
25
  */
27
- export function cookiePolicyFor(url) {
26
+ export function cookiePolicyFor(url, workspaceId) {
28
27
  const insecureLoopback = url.protocol === "http:" && isLoopbackHostname(url.hostname);
29
- return insecureLoopback
30
- ? { secure: false, names: LOOPBACK_NAMES }
31
- : { secure: true, names: SECURE_NAMES };
28
+ return {
29
+ secure: !insecureLoopback,
30
+ names: cookieNames(insecureLoopback ? "" : SECURE_PREFIX, workspaceId),
31
+ };
32
32
  }
33
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];
34
+ export function sessionCookieCandidates(hostname, workspaceId) {
35
+ const secure = cookieNames(SECURE_PREFIX, workspaceId).session;
36
+ return isLoopbackHostname(hostname) ? [secure, cookieNames("", workspaceId).session] : [secure];
37
+ }
38
+ function cookieNames(prefix, workspaceId) {
39
+ // Workspace ids are UUIDs; anything else is kept to cookie-name characters.
40
+ const scope = workspaceId.replace(/[^A-Za-z0-9_-]/g, "_");
41
+ return {
42
+ session: `${prefix}ss_app_session.${scope}`,
43
+ refresh: `${prefix}ss_app_refresh.${scope}`,
44
+ authFlowPrefix: `${prefix}ss_app_auth.${scope}.`,
45
+ };
46
+ }
47
+ /** The cookie holding one sign-in's flow, or undefined for a `state` this SDK never issues. */
48
+ export function authFlowCookieName(policy, state) {
49
+ return AUTH_FLOW_STATE.test(state) ? `${policy.names.authFlowPrefix}${state}` : undefined;
50
+ }
51
+ /** The sign-in flow cookies a request carries, one per sign-in still in progress. */
52
+ export function pendingAuthFlowCookies(policy, requestCookieNames) {
53
+ return requestCookieNames.filter((name) => name.startsWith(policy.names.authFlowPrefix));
38
54
  }
39
55
  function cookieAttributes(policy, maxAge) {
40
56
  return {
@@ -55,13 +71,10 @@ export function clearSessionCookies(response, policy) {
55
71
  clearCookie(response, policy, policy.names.refresh);
56
72
  }
57
73
  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);
74
+ response.cookies.set(`${policy.names.authFlowPrefix}${flow.state}`, encodeAuthFlowState(flow), cookieAttributes(policy, AUTH_FLOW_COOKIE_MAX_AGE_SECONDS));
62
75
  }
63
76
  // `__Host-` cookies are only replaced when the clearing cookie repeats Secure and Path=/.
64
- function clearCookie(response, policy, name) {
77
+ export function clearCookie(response, policy, name) {
65
78
  response.cookies.set(name, "", { ...cookieAttributes(policy, 0), expires: new Date(0) });
66
79
  }
67
80
  export function encodeAuthFlowState(flow) {
@@ -98,30 +111,6 @@ function isAuthFlowState(value) {
98
111
  typeof record.redirectUri === "string" &&
99
112
  typeof record.returnTo === "string");
100
113
  }
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
114
  /**
126
115
  * Replaces the named cookies in a raw `Cookie` request header while preserving every
127
116
  * other cookie byte for byte.
package/dist/data.d.ts ADDED
@@ -0,0 +1,126 @@
1
+ import type { ResolvedConfig } from "./config.js";
2
+ import type { QueryParameters } from "./parameters.js";
3
+ import type { Fetch } from "./sessions.js";
4
+ export { SegmentStreamApiError } from "./errors.js";
5
+ export type WarehouseValue = string | number | boolean | null;
6
+ export type WarehouseRow = Record<string, WarehouseValue>;
7
+ export interface WarehouseColumn {
8
+ readonly name: string;
9
+ readonly type: string;
10
+ readonly mode: string;
11
+ readonly description: string;
12
+ }
13
+ /** How much of a query result to wait for and collect. */
14
+ export interface WarehouseReadOptions {
15
+ /** Rows to collect across pages before stopping. Defaults to 10,000. */
16
+ readonly maxRows?: number;
17
+ /** Rows per API page, 1–1,000. Defaults to the smaller of `maxRows` and 1,000. */
18
+ readonly pageSize?: number;
19
+ /** How long to wait for a running query. Defaults to 60 seconds. */
20
+ readonly timeoutMs?: number;
21
+ readonly signal?: AbortSignal;
22
+ }
23
+ export interface WarehouseQueryOptions extends WarehouseReadOptions {
24
+ /** Values for named `@parameters` in the SQL. Never interpolate values into the SQL text. */
25
+ readonly params?: QueryParameters;
26
+ /** Lowers the workspace's per-query byte ceiling for this query. */
27
+ readonly maximumBytesBilled?: number | bigint | string;
28
+ }
29
+ export interface WarehouseQueryResult<Row = WarehouseRow> {
30
+ readonly queryId: string | null;
31
+ readonly columns: readonly WarehouseColumn[];
32
+ readonly rows: Row[];
33
+ /** Rows in the full result, which may exceed `rows` when `truncated`. */
34
+ readonly totalRows: number | null;
35
+ /** True when more rows exist than `maxRows` allowed. */
36
+ readonly truncated: boolean;
37
+ readonly bytesProcessed: string | null;
38
+ readonly bytesBilled: string | null;
39
+ readonly cacheHit: boolean | null;
40
+ }
41
+ export interface WarehouseDryRun {
42
+ readonly columns: readonly WarehouseColumn[];
43
+ readonly bytesProcessed: string | null;
44
+ readonly maximumBytesBilled: string;
45
+ }
46
+ export interface SemanticQueryRequest {
47
+ readonly dimensions: readonly string[];
48
+ readonly measures: readonly string[];
49
+ readonly dateRange: {
50
+ readonly from: string;
51
+ readonly to: string;
52
+ };
53
+ readonly granularity?: "day" | "week" | "month";
54
+ readonly filters?: ReadonlyArray<{
55
+ readonly dimension: string;
56
+ readonly operator: "equals" | "in" | "isNull" | "isNotNull";
57
+ readonly value?: string | number | boolean;
58
+ readonly values?: ReadonlyArray<string | number | boolean>;
59
+ }>;
60
+ readonly orderBy?: ReadonlyArray<{
61
+ readonly measure?: string;
62
+ readonly dimension?: string;
63
+ readonly direction: "asc" | "desc";
64
+ }>;
65
+ readonly limit?: number;
66
+ }
67
+ export interface SemanticField {
68
+ readonly name: string;
69
+ readonly label: string;
70
+ readonly type: string;
71
+ readonly unit?: string;
72
+ readonly format?: string;
73
+ }
74
+ export interface SemanticQueryResult<Row = Record<string, string | boolean | null>> {
75
+ readonly deploymentId: string | null;
76
+ readonly definitionDigest: string;
77
+ readonly fields: readonly SemanticField[];
78
+ readonly rows: Row[];
79
+ readonly truncated: boolean;
80
+ readonly execution: {
81
+ readonly elapsedMs: number;
82
+ readonly bytesProcessed?: string;
83
+ readonly bytesBilled?: string;
84
+ };
85
+ }
86
+ export interface WarehouseClient {
87
+ /** Runs read-only SQL in the workspace warehouse as the signed-in user's app session. */
88
+ readonly query: <Row = WarehouseRow>(sql: string, options?: WarehouseQueryOptions) => Promise<WarehouseQueryResult<Row>>;
89
+ /**
90
+ * Resumes a query by ID from a later request. Queries keep running in the warehouse for
91
+ * up to an hour after `query()` stops waiting, and each request uses a fresh app token.
92
+ */
93
+ readonly result: <Row = WarehouseRow>(queryId: string, options?: WarehouseReadOptions) => Promise<WarehouseQueryResult<Row>>;
94
+ /** Validates SQL and estimates bytes processed without running it. */
95
+ readonly dryRun: (sql: string, options?: Pick<WarehouseQueryOptions, "params" | "maximumBytesBilled" | "signal">) => Promise<WarehouseDryRun>;
96
+ }
97
+ export interface SemanticClient {
98
+ readonly list: () => Promise<{
99
+ readonly deploymentId: string | null;
100
+ readonly models: readonly unknown[];
101
+ }>;
102
+ readonly get: (model: string) => Promise<{
103
+ readonly deploymentId: string | null;
104
+ readonly definitionDigest: string;
105
+ readonly model: unknown;
106
+ }>;
107
+ readonly query: <Row = Record<string, string | boolean | null>>(model: string, request: SemanticQueryRequest) => Promise<SemanticQueryResult<Row>>;
108
+ }
109
+ export interface DataClientDependencies {
110
+ readonly config: ResolvedConfig;
111
+ readonly fetch: Fetch;
112
+ readonly getAccessToken: () => Promise<string | null>;
113
+ /**
114
+ * The workspace app credential, `SEGMENTSTREAM_APP_CREDENTIAL`, read on every call. The API
115
+ * accepts it for warehouse queries and their results; without it they use the session token.
116
+ */
117
+ readonly getAppCredential?: () => string | undefined;
118
+ readonly sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
119
+ readonly now?: () => number;
120
+ }
121
+ /** Server-side SegmentStream API access as the signed-in member. */
122
+ export declare function createDataClient(dependencies: DataClientDependencies): {
123
+ fetch: (path: string, init?: RequestInit) => Promise<Response>;
124
+ warehouse: WarehouseClient;
125
+ semantic: SemanticClient;
126
+ };
package/dist/data.js ADDED
@@ -0,0 +1,206 @@
1
+ import { SegmentStreamApiError, readJson } from "./errors.js";
2
+ import { encodeParameters } from "./parameters.js";
3
+ export { SegmentStreamApiError } from "./errors.js";
4
+ const REQUEST_TIMEOUT_MS = 45_000;
5
+ const DEFAULT_MAX_ROWS = 10_000;
6
+ const MAXIMUM_PAGE_SIZE = 1000;
7
+ const DEFAULT_TIMEOUT_MS = 60_000;
8
+ const FIRST_POLL_MS = 500;
9
+ const LONGEST_POLL_MS = 2000;
10
+ /** Server-side SegmentStream API access as the signed-in member. */
11
+ export function createDataClient(dependencies) {
12
+ const { config } = dependencies;
13
+ const workspace = `/api/v1/workspaces/${encodeURIComponent(config.workspaceId)}`;
14
+ const sleep = dependencies.sleep ?? defaultSleep;
15
+ const now = dependencies.now ?? Date.now;
16
+ /** Calls any SegmentStream API path as the signed-in member; the token never leaves the server. */
17
+ function apiFetch(path, init = {}) {
18
+ return authorizedFetch(path, init, dependencies.getAccessToken);
19
+ }
20
+ /** Warehouse queries prefer the app's own credential, which needs no SegmentStream session. */
21
+ async function warehouseToken() {
22
+ return dependencies.getAppCredential?.() ?? dependencies.getAccessToken();
23
+ }
24
+ async function authorizedFetch(path, init, getToken) {
25
+ if (!path.startsWith("/api/")) {
26
+ throw new TypeError('SegmentStream API paths start with "/api/", for example "/api/v1/me".');
27
+ }
28
+ const token = await getToken();
29
+ if (token === null) {
30
+ throw new SegmentStreamApiError({
31
+ code: "app_session_missing",
32
+ status: 401,
33
+ detail: "No signed-in SegmentStream session for this request; call SegmentStream from Server Components, Route Handlers, or Server Functions behind the SegmentStream proxy.",
34
+ });
35
+ }
36
+ const headers = new Headers(init.headers);
37
+ const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
38
+ headers.set("authorization", `Bearer ${token}`);
39
+ if (!headers.has("accept")) {
40
+ headers.set("accept", "application/json");
41
+ }
42
+ try {
43
+ return await dependencies.fetch(`${config.baseUrl}${path}`, {
44
+ ...init,
45
+ headers,
46
+ cache: "no-store",
47
+ redirect: "error",
48
+ signal: init.signal ? AbortSignal.any([init.signal, timeout]) : timeout,
49
+ });
50
+ }
51
+ catch (error) {
52
+ if (init.signal?.aborted) {
53
+ throw error;
54
+ }
55
+ throw new SegmentStreamApiError({
56
+ code: "segmentstream_unreachable",
57
+ status: 503,
58
+ detail: "SegmentStream could not be reached.",
59
+ retryable: true,
60
+ });
61
+ }
62
+ }
63
+ async function call(method, path, body, signal, getToken = dependencies.getAccessToken) {
64
+ const response = await authorizedFetch(path, {
65
+ method,
66
+ ...(body === undefined
67
+ ? {}
68
+ : { body: JSON.stringify(body), headers: { "content-type": "application/json" } }),
69
+ ...(signal ? { signal } : {}),
70
+ }, getToken);
71
+ const payload = await readJson(response);
72
+ if (!response.ok) {
73
+ throw errorFrom(response.status, payload);
74
+ }
75
+ return payload;
76
+ }
77
+ function limits(options) {
78
+ const maxRows = positiveInteger(options.maxRows ?? DEFAULT_MAX_ROWS, "maxRows");
79
+ const pageSize = positiveInteger(options.pageSize ?? Math.min(maxRows, MAXIMUM_PAGE_SIZE), "pageSize");
80
+ return { maxRows, pageSize, deadline: now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS) };
81
+ }
82
+ /** Waits for a running query, then follows pages up to `maxRows`. */
83
+ async function collect(first, { maxRows, pageSize, deadline }, signal) {
84
+ let page = first;
85
+ const queryId = page.id ?? null;
86
+ let wait = FIRST_POLL_MS;
87
+ while (page.state === "running") {
88
+ if (queryId === null || now() + wait > deadline) {
89
+ throw new SegmentStreamApiError({
90
+ code: "query_timeout",
91
+ status: 504,
92
+ detail: "The query is still running; resume it from a later request with warehouse.result(queryId).",
93
+ retryable: true,
94
+ queryId: queryId ?? undefined,
95
+ });
96
+ }
97
+ await sleep(wait, signal);
98
+ wait = Math.min(wait * 2, LONGEST_POLL_MS);
99
+ page = (await read(queryId, pageSize, undefined, signal)).query;
100
+ }
101
+ const head = page;
102
+ const rows = [...page.rows];
103
+ while (page.nextPageToken !== undefined && rows.length < maxRows && queryId !== null) {
104
+ page = (await read(queryId, pageSize, page.nextPageToken, signal)).query;
105
+ rows.push(...page.rows);
106
+ }
107
+ return {
108
+ queryId,
109
+ columns: head.columns,
110
+ rows: rows.slice(0, maxRows),
111
+ totalRows: head.totalRows === undefined ? null : Number(head.totalRows),
112
+ truncated: rows.length > maxRows || page.nextPageToken !== undefined,
113
+ bytesProcessed: head.bytesProcessed ?? null,
114
+ bytesBilled: head.bytesBilled ?? null,
115
+ cacheHit: head.cacheHit ?? null,
116
+ };
117
+ }
118
+ async function query(sql, options = {}) {
119
+ const bounds = limits(options);
120
+ const { query: page } = await call("POST", `${workspace}/warehouse/queries`, {
121
+ sql,
122
+ parameters: encodeParameters(options.params),
123
+ ...bytesLimit(options.maximumBytesBilled),
124
+ pageSize: bounds.pageSize,
125
+ }, options.signal, warehouseToken);
126
+ return collect(page, bounds, options.signal);
127
+ }
128
+ async function result(queryId, options = {}) {
129
+ const bounds = limits(options);
130
+ const { query: page } = await read(queryId, bounds.pageSize, undefined, options.signal);
131
+ return collect(page, bounds, options.signal);
132
+ }
133
+ function read(queryId, pageSize, pageToken, signal) {
134
+ const search = new URLSearchParams({ pageSize: String(pageSize) });
135
+ if (pageToken !== undefined) {
136
+ search.set("pageToken", pageToken);
137
+ }
138
+ return call("GET", `${workspace}/warehouse/queries/${encodeURIComponent(queryId)}?${search.toString()}`, undefined, signal, warehouseToken);
139
+ }
140
+ const warehouse = {
141
+ query,
142
+ result,
143
+ dryRun: async (sql, options = {}) => {
144
+ const { query: page } = await call("POST", `${workspace}/warehouse/queries`, {
145
+ sql,
146
+ parameters: encodeParameters(options.params),
147
+ ...bytesLimit(options.maximumBytesBilled),
148
+ dryRun: true,
149
+ }, options.signal, warehouseToken);
150
+ return {
151
+ columns: page.columns,
152
+ bytesProcessed: page.bytesProcessed ?? null,
153
+ maximumBytesBilled: page.maximumBytesBilled,
154
+ };
155
+ },
156
+ };
157
+ const semantic = {
158
+ list: () => call("GET", `${workspace}/semantic-models`),
159
+ get: (model) => call("GET", `${workspace}/semantic-models/${encodeURIComponent(model)}`),
160
+ query: (model, request) => call("POST", `${workspace}/semantic-models/${encodeURIComponent(model)}/query`, request),
161
+ };
162
+ return { fetch: apiFetch, warehouse, semantic };
163
+ }
164
+ function bytesLimit(value) {
165
+ if (value === undefined) {
166
+ return {};
167
+ }
168
+ const text = String(value);
169
+ if (!/^[1-9][0-9]*$/.test(text)) {
170
+ throw new TypeError("maximumBytesBilled must be a positive whole number of bytes.");
171
+ }
172
+ return { maximumBytesBilled: text };
173
+ }
174
+ function positiveInteger(value, name) {
175
+ if (!Number.isInteger(value) || value < 1) {
176
+ throw new TypeError(`${name} must be a positive integer.`);
177
+ }
178
+ return value;
179
+ }
180
+ function errorFrom(status, payload) {
181
+ const body = typeof payload === "object" && payload !== null ? payload : {};
182
+ return new SegmentStreamApiError({
183
+ code: typeof body.error === "string" ? body.error : `http_${status}`,
184
+ status,
185
+ detail: typeof body.detail === "string" ? body.detail : "SegmentStream rejected the request.",
186
+ requestId: typeof body.requestId === "string" ? body.requestId : undefined,
187
+ retryable: typeof body.retryable === "boolean" ? body.retryable : status >= 500,
188
+ });
189
+ }
190
+ function defaultSleep(ms, signal) {
191
+ return new Promise((resolve, reject) => {
192
+ if (signal?.aborted) {
193
+ reject(signal.reason);
194
+ return;
195
+ }
196
+ const timer = setTimeout(() => {
197
+ signal?.removeEventListener("abort", abort);
198
+ resolve();
199
+ }, ms);
200
+ const abort = () => {
201
+ clearTimeout(timer);
202
+ reject(signal?.reason);
203
+ };
204
+ signal?.addEventListener("abort", abort, { once: true });
205
+ });
206
+ }
@@ -0,0 +1,36 @@
1
+ /** One problem with a request's input, as the ontology service reports it. */
2
+ export interface ApiIssue {
3
+ readonly path: ReadonlyArray<string | number>;
4
+ readonly message: string;
5
+ }
6
+ /**
7
+ * A failed SegmentStream or ontology request; `code` is the API error code, such as `not_invited`,
8
+ * `forbidden`, `action_rejected` or `query_timeout`.
9
+ */
10
+ export declare class SegmentStreamApiError extends Error {
11
+ readonly code: string;
12
+ readonly status: number;
13
+ readonly detail: string;
14
+ readonly requestId: string | undefined;
15
+ readonly retryable: boolean;
16
+ /** The warehouse query this error concerns, when one was started. */
17
+ readonly queryId: string | undefined;
18
+ /** What was wrong with the input, for `invalid_request`, `invalid_input` and `action_rejected`. */
19
+ readonly issues: ReadonlyArray<ApiIssue>;
20
+ constructor(input: {
21
+ readonly code: string;
22
+ readonly status: number;
23
+ readonly detail: string;
24
+ readonly requestId?: string | undefined;
25
+ readonly retryable?: boolean;
26
+ readonly queryId?: string | undefined;
27
+ readonly issues?: ReadonlyArray<ApiIssue>;
28
+ /** The underlying failure, kept for server logs. */
29
+ readonly cause?: unknown;
30
+ });
31
+ }
32
+ /** The person signed in, but the ontology has no member row for them. */
33
+ export declare const NOT_INVITED = "not_invited";
34
+ /** The error body of a failed response: `{ error, detail, requestId, issues? }`. */
35
+ export declare function apiErrorFrom(status: number, payload: unknown, fallbackDetail: string): SegmentStreamApiError;
36
+ export declare function readJson(response: Response): Promise<unknown>;
package/dist/errors.js ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * A failed SegmentStream or ontology request; `code` is the API error code, such as `not_invited`,
3
+ * `forbidden`, `action_rejected` or `query_timeout`.
4
+ */
5
+ export class SegmentStreamApiError extends Error {
6
+ code;
7
+ status;
8
+ detail;
9
+ requestId;
10
+ retryable;
11
+ /** The warehouse query this error concerns, when one was started. */
12
+ queryId;
13
+ /** What was wrong with the input, for `invalid_request`, `invalid_input` and `action_rejected`. */
14
+ issues;
15
+ constructor(input) {
16
+ super(`${input.detail} (${input.code})`, input.cause === undefined ? undefined : { cause: input.cause });
17
+ this.name = "SegmentStreamApiError";
18
+ this.code = input.code;
19
+ this.status = input.status;
20
+ this.detail = input.detail;
21
+ this.requestId = input.requestId;
22
+ this.retryable = input.retryable ?? false;
23
+ this.queryId = input.queryId;
24
+ this.issues = input.issues ?? [];
25
+ }
26
+ }
27
+ /** The person signed in, but the ontology has no member row for them. */
28
+ export const NOT_INVITED = "not_invited";
29
+ /** The error body of a failed response: `{ error, detail, requestId, issues? }`. */
30
+ export function apiErrorFrom(status, payload, fallbackDetail) {
31
+ const body = typeof payload === "object" && payload !== null ? payload : {};
32
+ return new SegmentStreamApiError({
33
+ code: typeof body.error === "string" ? body.error : `http_${status}`,
34
+ status,
35
+ detail: typeof body.detail === "string" ? body.detail : fallbackDetail,
36
+ requestId: typeof body.requestId === "string" ? body.requestId : undefined,
37
+ retryable: typeof body.retryable === "boolean" ? body.retryable : status >= 500 || status === 429,
38
+ issues: issuesFrom(body.issues),
39
+ });
40
+ }
41
+ function issuesFrom(value) {
42
+ if (!Array.isArray(value)) {
43
+ return [];
44
+ }
45
+ return value.flatMap((candidate) => {
46
+ if (typeof candidate !== "object" || candidate === null) {
47
+ return [];
48
+ }
49
+ const { path, message } = candidate;
50
+ if (typeof message !== "string") {
51
+ return [];
52
+ }
53
+ const segments = Array.isArray(path)
54
+ ? path.filter((segment) => typeof segment === "string" || typeof segment === "number")
55
+ : [];
56
+ return [{ path: segments, message }];
57
+ });
58
+ }
59
+ export async function readJson(response) {
60
+ try {
61
+ return await response.json();
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
package/dist/handlers.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { NextResponse } from "next/server";
2
- import { clearAuthFlowCookie, clearSessionCookies, cookiePolicyFor, decodeAuthFlowState, safeReturnTo, setSessionCookies, } from "./cookies.js";
2
+ import { authFlowCookieName, clearCookie, clearSessionCookies, cookiePolicyFor, decodeAuthFlowState, pendingAuthFlowCookies, safeReturnTo, setSessionCookies, } from "./cookies.js";
3
3
  import { constantTimeEqual } from "./pkce.js";
4
4
  import { CALLBACK_PATH, SIGNED_OUT_PATH, SIGN_IN_PATH, SDK_ROUTE_PREFIX, signInRedirect, } from "./sign-in.js";
5
5
  export function createHandlers(context) {
@@ -29,19 +29,25 @@ export function createHandlers(context) {
29
29
  return { GET, POST };
30
30
  }
31
31
  async function handleCallback(context, request) {
32
- const policy = cookiePolicyFor(request.nextUrl);
33
- const flow = decodeAuthFlowState(request.cookies.get(policy.names.authFlow)?.value);
32
+ const policy = cookiePolicyFor(request.nextUrl, context.config.workspaceId);
34
33
  const code = request.nextUrl.searchParams.get("code");
35
34
  const state = request.nextUrl.searchParams.get("state");
35
+ const flowCookie = state === null ? undefined : authFlowCookieName(policy, state);
36
+ const flow = flowCookie === undefined ? null : decodeAuthFlowState(request.cookies.get(flowCookie)?.value);
36
37
  if (request.nextUrl.searchParams.get("error") === "access_denied") {
37
- return callbackFailure(policy, 403, "You don't have access to this app.");
38
+ return callbackFailure(policy, flowCookie, 403, "You don't have access to this app.");
38
39
  }
39
- if (flow === null ||
40
+ if (state === null ||
41
+ flowCookie === undefined ||
42
+ flow === null ||
40
43
  code === null ||
41
44
  code === "" ||
42
- state === null ||
43
45
  !constantTimeEqual(state, flow.state)) {
44
- return callbackFailure(policy, 400, "This sign-in link is invalid or has expired.");
46
+ // A replayed or abandoned sign-in whose browser has signed in since, for example in another tab.
47
+ if (await hasSession(context, request, policy)) {
48
+ return returnToApp(request, policy, flowCookie, "/");
49
+ }
50
+ return callbackFailure(policy, flowCookie, 400, "This sign-in link is invalid or has expired.");
45
51
  }
46
52
  const exchange = await context.requestToken({
47
53
  grantType: "authorization_code",
@@ -50,47 +56,65 @@ async function handleCallback(context, request) {
50
56
  redirectUri: flow.redirectUri,
51
57
  });
52
58
  if (exchange.status === "unavailable") {
53
- return callbackFailure(policy, 503, "Sign-in is temporarily unavailable. Try again in a moment.");
59
+ return callbackFailure(policy, flowCookie, 503, "Sign-in is temporarily unavailable. Try again in a moment.");
54
60
  }
55
61
  if (exchange.status === "rejected") {
56
62
  const accessRevoked = exchange.httpStatus === 403 || exchange.code === "access_revoked";
57
63
  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.");
64
+ ? callbackFailure(policy, flowCookie, 403, "You don't have access to this app.")
65
+ : callbackFailure(policy, flowCookie, 400, "Sign-in could not be completed. Try again.");
60
66
  }
61
67
  // A token this app would reject must not become a session, or sign-in would loop.
62
68
  const verification = await context.verifyToken(exchange.tokens.accessToken);
63
69
  if (verification.status === "unavailable") {
64
- return callbackFailure(policy, 503, "Sign-in is temporarily unavailable. Try again in a moment.");
70
+ return callbackFailure(policy, flowCookie, 503, "Sign-in is temporarily unavailable. Try again in a moment.");
65
71
  }
66
72
  if (verification.status !== "valid") {
67
- return callbackFailure(policy, 400, "Sign-in returned a session for a different app.");
73
+ return callbackFailure(policy, flowCookie, 400, "Sign-in returned a session for a different app.");
68
74
  }
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);
75
+ const response = returnToApp(request, policy, flowCookie, flow.returnTo);
72
76
  setSessionCookies(response, policy, exchange.tokens);
73
77
  return response;
74
78
  }
79
+ async function hasSession(context, request, policy) {
80
+ const accessToken = request.cookies.get(policy.names.session)?.value;
81
+ if (accessToken === undefined || accessToken === "") {
82
+ return false;
83
+ }
84
+ return (await context.verifyToken(accessToken)).status === "valid";
85
+ }
86
+ function returnToApp(request, policy, flowCookie, returnTo) {
87
+ const response = NextResponse.redirect(new URL(returnTo, request.nextUrl.origin), 303);
88
+ response.headers.set("cache-control", "no-store");
89
+ if (flowCookie !== undefined) {
90
+ clearCookie(response, policy, flowCookie);
91
+ }
92
+ return response;
93
+ }
75
94
  async function handleSignOut(context, request) {
76
95
  const origin = request.headers.get("origin");
77
96
  if (origin !== null && origin !== request.nextUrl.origin) {
78
97
  return htmlResponse(403, "Sign-out refused", "Sign out from this app's own pages.", "Return to the app");
79
98
  }
80
- const policy = cookiePolicyFor(request.nextUrl);
99
+ const policy = cookiePolicyFor(request.nextUrl, context.config.workspaceId);
81
100
  const refreshToken = request.cookies.get(policy.names.refresh)?.value;
82
101
  if (refreshToken !== undefined && refreshToken !== "") {
83
102
  await context.revokeSession(refreshToken);
84
103
  }
85
104
  const response = NextResponse.redirect(new URL(SIGNED_OUT_PATH, request.nextUrl.origin), 303);
105
+ const pendingFlows = pendingAuthFlowCookies(policy, request.cookies.getAll().map((cookie) => cookie.name));
86
106
  response.headers.set("cache-control", "no-store");
87
107
  clearSessionCookies(response, policy);
88
- clearAuthFlowCookie(response, policy);
108
+ for (const name of pendingFlows) {
109
+ clearCookie(response, policy, name);
110
+ }
89
111
  return response;
90
112
  }
91
- function callbackFailure(policy, status, message) {
113
+ function callbackFailure(policy, flowCookie, status, message) {
92
114
  const response = htmlResponse(status, "Sign-in failed", message, "Try again");
93
- clearAuthFlowCookie(response, policy);
115
+ if (flowCookie !== undefined) {
116
+ clearCookie(response, policy, flowCookie);
117
+ }
94
118
  return response;
95
119
  }
96
120
  function notFound() {