@segmentstream-cloud/app 0.2.0 → 0.3.1

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 (47) hide show
  1. package/README.md +198 -87
  2. package/dist/app.d.ts +35 -8
  3. package/dist/app.js +73 -8
  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 +6 -18
  9. package/dist/data.js +17 -36
  10. package/dist/errors.d.ts +38 -0
  11. package/dist/errors.js +68 -0
  12. package/dist/handlers.js +43 -19
  13. package/dist/index.d.ts +18 -8
  14. package/dist/index.js +11 -6
  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 +471 -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/permissions.d.ts +17 -0
  24. package/dist/permissions.js +33 -0
  25. package/dist/proxy.d.ts +2 -0
  26. package/dist/proxy.js +29 -13
  27. package/dist/return-to.d.ts +2 -0
  28. package/dist/return-to.js +24 -0
  29. package/dist/server.d.ts +3 -0
  30. package/dist/server.js +35 -5
  31. package/dist/sign-in.d.ts +5 -1
  32. package/dist/sign-in.js +17 -4
  33. package/dist/tokens.d.ts +22 -3
  34. package/dist/ui/components.d.ts +27 -0
  35. package/dist/ui/components.js +63 -0
  36. package/dist/ui/hooks.d.ts +19 -0
  37. package/dist/ui/hooks.js +56 -0
  38. package/dist/ui/index.d.ts +10 -0
  39. package/dist/ui/index.js +8 -0
  40. package/dist/ui/navigation.d.ts +5 -0
  41. package/dist/ui/navigation.js +8 -0
  42. package/dist/ui/sign-in.d.ts +20 -0
  43. package/dist/ui/sign-in.js +200 -0
  44. package/dist/ui/store.d.ts +61 -0
  45. package/dist/ui/store.js +155 -0
  46. package/dist/ui/styles.css +249 -0
  47. package/package.json +24 -7
package/dist/cookies.d.ts CHANGED
@@ -1,12 +1,16 @@
1
1
  import type { NextResponse } from "next/server";
2
+ export { safeReturnTo } from "./return-to.js";
2
3
  export declare const REFRESH_COOKIE_MAX_AGE_SECONDS: number;
3
4
  export declare const AUTH_FLOW_COOKIE_MAX_AGE_SECONDS: number;
5
+ /** Sign-ins one browser can have in progress at once; one more abandons them all. */
6
+ export declare const MAX_PENDING_AUTH_FLOWS = 4;
4
7
  export interface CookieNames {
5
8
  readonly session: string;
6
9
  readonly refresh: string;
7
- readonly authFlow: string;
10
+ /** Each sign-in in progress has its own cookie: this prefix followed by its `state`. */
11
+ readonly authFlowPrefix: string;
8
12
  }
9
- /** Cookie names and attributes for one request origin. */
13
+ /** Cookie names and attributes for one app on one request origin. */
10
14
  export interface CookiePolicy {
11
15
  readonly secure: boolean;
12
16
  readonly names: CookieNames;
@@ -28,18 +32,23 @@ export declare function isLoopbackHostname(hostname: string): boolean;
28
32
  * Browsers reject `Secure` and `__Host-` cookies on plain-HTTP origins, except that
29
33
  * local development on http://localhost should still work. Every other origin uses
30
34
  * `__Host-` cookies, which cannot be set by sibling subdomains.
35
+ *
36
+ * Browsers share cookies between ports, so apps on `localhost:3000` and `localhost:3001`
37
+ * see one cookie jar. Names carry the workspace id to keep each app's session its own.
31
38
  */
32
- export declare function cookiePolicyFor(url: URL): CookiePolicy;
39
+ export declare function cookiePolicyFor(url: URL, workspaceId: string): CookiePolicy;
33
40
  /** Candidate cookie names for code that only knows the request host (Server Components). */
34
- export declare function sessionCookieCandidates(hostname: string): readonly string[];
41
+ export declare function sessionCookieCandidates(hostname: string, workspaceId: string): readonly string[];
42
+ /** The cookie holding one sign-in's flow, or undefined for a `state` this SDK never issues. */
43
+ export declare function authFlowCookieName(policy: CookiePolicy, state: string): string | undefined;
44
+ /** The sign-in flow cookies a request carries, one per sign-in still in progress. */
45
+ export declare function pendingAuthFlowCookies(policy: CookiePolicy, requestCookieNames: readonly string[]): string[];
35
46
  export declare function setSessionCookies(response: NextResponse, policy: CookiePolicy, tokens: SessionTokens): void;
36
47
  export declare function clearSessionCookies(response: NextResponse, policy: CookiePolicy): void;
37
48
  export declare function setAuthFlowCookie(response: NextResponse, policy: CookiePolicy, flow: AuthFlowState): void;
38
- export declare function clearAuthFlowCookie(response: NextResponse, policy: CookiePolicy): void;
49
+ export declare function clearCookie(response: NextResponse, policy: CookiePolicy, name: string): void;
39
50
  export declare function encodeAuthFlowState(flow: AuthFlowState): string;
40
51
  export declare function decodeAuthFlowState(value: string | undefined): AuthFlowState | null;
41
- /** Reduces a return path to a same-origin relative path, falling back to `/`. */
42
- export declare function safeReturnTo(value: string | null | undefined): string;
43
52
  /**
44
53
  * Replaces the named cookies in a raw `Cookie` request header while preserving every
45
54
  * other cookie byte for byte.
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 CHANGED
@@ -1,24 +1,7 @@
1
1
  import type { ResolvedConfig } from "./config.js";
2
2
  import type { QueryParameters } from "./parameters.js";
3
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
- }
4
+ export { SegmentStreamApiError } from "./errors.js";
22
5
  export type WarehouseValue = string | number | boolean | null;
23
6
  export type WarehouseRow = Record<string, WarehouseValue>;
24
7
  export interface WarehouseColumn {
@@ -127,6 +110,11 @@ export interface DataClientDependencies {
127
110
  readonly config: ResolvedConfig;
128
111
  readonly fetch: Fetch;
129
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;
130
118
  readonly sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
131
119
  readonly now?: () => number;
132
120
  }
package/dist/data.js CHANGED
@@ -1,24 +1,6 @@
1
+ import { SegmentStreamApiError, readJson } from "./errors.js";
1
2
  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
- }
3
+ export { SegmentStreamApiError } from "./errors.js";
22
4
  const REQUEST_TIMEOUT_MS = 45_000;
23
5
  const DEFAULT_MAX_ROWS = 10_000;
24
6
  const MAXIMUM_PAGE_SIZE = 1000;
@@ -32,11 +14,18 @@ export function createDataClient(dependencies) {
32
14
  const sleep = dependencies.sleep ?? defaultSleep;
33
15
  const now = dependencies.now ?? Date.now;
34
16
  /** Calls any SegmentStream API path as the signed-in member; the token never leaves the server. */
35
- async function apiFetch(path, init = {}) {
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) {
36
25
  if (!path.startsWith("/api/")) {
37
26
  throw new TypeError('SegmentStream API paths start with "/api/", for example "/api/v1/me".');
38
27
  }
39
- const token = await dependencies.getAccessToken();
28
+ const token = await getToken();
40
29
  if (token === null) {
41
30
  throw new SegmentStreamApiError({
42
31
  code: "app_session_missing",
@@ -71,14 +60,14 @@ export function createDataClient(dependencies) {
71
60
  });
72
61
  }
73
62
  }
74
- async function call(method, path, body, signal) {
75
- const response = await apiFetch(path, {
63
+ async function call(method, path, body, signal, getToken = dependencies.getAccessToken) {
64
+ const response = await authorizedFetch(path, {
76
65
  method,
77
66
  ...(body === undefined
78
67
  ? {}
79
68
  : { body: JSON.stringify(body), headers: { "content-type": "application/json" } }),
80
69
  ...(signal ? { signal } : {}),
81
- });
70
+ }, getToken);
82
71
  const payload = await readJson(response);
83
72
  if (!response.ok) {
84
73
  throw errorFrom(response.status, payload);
@@ -133,7 +122,7 @@ export function createDataClient(dependencies) {
133
122
  parameters: encodeParameters(options.params),
134
123
  ...bytesLimit(options.maximumBytesBilled),
135
124
  pageSize: bounds.pageSize,
136
- }, options.signal);
125
+ }, options.signal, warehouseToken);
137
126
  return collect(page, bounds, options.signal);
138
127
  }
139
128
  async function result(queryId, options = {}) {
@@ -146,7 +135,7 @@ export function createDataClient(dependencies) {
146
135
  if (pageToken !== undefined) {
147
136
  search.set("pageToken", pageToken);
148
137
  }
149
- return call("GET", `${workspace}/warehouse/queries/${encodeURIComponent(queryId)}?${search.toString()}`, undefined, signal);
138
+ return call("GET", `${workspace}/warehouse/queries/${encodeURIComponent(queryId)}?${search.toString()}`, undefined, signal, warehouseToken);
150
139
  }
151
140
  const warehouse = {
152
141
  query,
@@ -157,7 +146,7 @@ export function createDataClient(dependencies) {
157
146
  parameters: encodeParameters(options.params),
158
147
  ...bytesLimit(options.maximumBytesBilled),
159
148
  dryRun: true,
160
- }, options.signal);
149
+ }, options.signal, warehouseToken);
161
150
  return {
162
151
  columns: page.columns,
163
152
  bytesProcessed: page.bytesProcessed ?? null,
@@ -188,14 +177,6 @@ function positiveInteger(value, name) {
188
177
  }
189
178
  return value;
190
179
  }
191
- async function readJson(response) {
192
- try {
193
- return await response.json();
194
- }
195
- catch {
196
- return null;
197
- }
198
- }
199
180
  function errorFrom(status, payload) {
200
181
  const body = typeof payload === "object" && payload !== null ? payload : {};
201
182
  return new SegmentStreamApiError({
@@ -0,0 +1,38 @@
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
+ /** An emailed sign-in link that Neon Auth rejected: expired, already used or unknown. */
35
+ export declare const SIGN_IN_LINK_INVALID = "sign_in_link_invalid";
36
+ /** The error body of a failed response: `{ error, detail, requestId, issues? }`. */
37
+ export declare function apiErrorFrom(status: number, payload: unknown, fallbackDetail: string): SegmentStreamApiError;
38
+ export declare function readJson(response: Response): Promise<unknown>;
package/dist/errors.js ADDED
@@ -0,0 +1,68 @@
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
+ /** An emailed sign-in link that Neon Auth rejected: expired, already used or unknown. */
30
+ export const SIGN_IN_LINK_INVALID = "sign_in_link_invalid";
31
+ /** The error body of a failed response: `{ error, detail, requestId, issues? }`. */
32
+ export function apiErrorFrom(status, payload, fallbackDetail) {
33
+ const body = typeof payload === "object" && payload !== null ? payload : {};
34
+ return new SegmentStreamApiError({
35
+ code: typeof body.error === "string" ? body.error : `http_${status}`,
36
+ status,
37
+ detail: typeof body.detail === "string" ? body.detail : fallbackDetail,
38
+ requestId: typeof body.requestId === "string" ? body.requestId : undefined,
39
+ retryable: typeof body.retryable === "boolean" ? body.retryable : status >= 500 || status === 429,
40
+ issues: issuesFrom(body.issues),
41
+ });
42
+ }
43
+ function issuesFrom(value) {
44
+ if (!Array.isArray(value)) {
45
+ return [];
46
+ }
47
+ return value.flatMap((candidate) => {
48
+ if (typeof candidate !== "object" || candidate === null) {
49
+ return [];
50
+ }
51
+ const { path, message } = candidate;
52
+ if (typeof message !== "string") {
53
+ return [];
54
+ }
55
+ const segments = Array.isArray(path)
56
+ ? path.filter((segment) => typeof segment === "string" || typeof segment === "number")
57
+ : [];
58
+ return [{ path: segments, message }];
59
+ });
60
+ }
61
+ export async function readJson(response) {
62
+ try {
63
+ return await response.json();
64
+ }
65
+ catch {
66
+ return null;
67
+ }
68
+ }
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() {
package/dist/index.d.ts CHANGED
@@ -1,19 +1,29 @@
1
1
  import type { SegmentStreamApp } from "./app.js";
2
2
  import type { SegmentStreamAppConfig } from "./config.js";
3
3
  /**
4
- * Creates the SegmentStream sign-in integration for a Next.js workspace app.
4
+ * Creates the SegmentStream integration for a Next.js workspace app. Pass the type of the app's
5
+ * ontology to type `segmentstream.ontology`:
5
6
  *
6
7
  * ```ts
7
- * export const segmentstream = createSegmentStreamApp({
8
- * workspaceId: "wsp_...",
9
- * organizationId: "org_...",
10
- * });
8
+ * import type ontology from "../../ontology";
9
+ * import link from "../segmentstream.json";
10
+ *
11
+ * export const segmentstream = createSegmentStreamApp<typeof ontology>(link);
11
12
  * ```
13
+ *
14
+ * With `NEON_AUTH_BASE_URL` set, users sign in with Neon Auth and their roles and permissions come
15
+ * from the ontology. Without it, members sign in with their SegmentStream account, as in 0.2.
12
16
  */
13
- export declare function createSegmentStreamApp(config: SegmentStreamAppConfig): SegmentStreamApp;
14
- export { SegmentStreamApiError } from "./data.js";
17
+ export declare function createSegmentStreamApp<O = unknown>(config: SegmentStreamAppConfig): SegmentStreamApp<O>;
18
+ export { NOT_INVITED, SIGN_IN_LINK_INVALID, SegmentStreamApiError } from "./errors.js";
19
+ export { grants, isAllowed } from "./permissions.js";
15
20
  export { sql } from "./parameters.js";
16
- export type { SegmentStreamApp } from "./app.js";
21
+ export type { AuthenticationMethod, SegmentStreamApp } from "./app.js";
22
+ export type { ApiIssue } from "./errors.js";
23
+ export type { CurrentUser } from "./me.js";
24
+ export type { AuthRouteHandler, AuthRouteHandlers, SignOutOptions } from "./neon.js";
25
+ export type { ActionCall, ActionOptions, ActionResponse, Change, Edit, EditKind, EntityClient, FieldFilter, GeneratedActions, HistoryEntry, HistoryOptions, HistoryPage, JsonRecord, JsonValue, ManyFilter, OntologyClient, OntologyPage, OntologyQuery, OrderBy, RequestOptions, UntypedOntologyClient, ValidationIssue, Where, } from "./ontology.js";
26
+ export type { PermissionHolder } from "./permissions.js";
17
27
  export type { SemanticClient, SemanticField, SemanticQueryRequest, SemanticQueryResult, WarehouseClient, WarehouseColumn, WarehouseDryRun, WarehouseQueryOptions, WarehouseReadOptions, WarehouseQueryResult, WarehouseRow, WarehouseValue, } from "./data.js";
18
28
  export type { ParameterType, QueryParameterValue, QueryParameters, TypedParameter, } from "./parameters.js";
19
29
  export type { SegmentStreamAppConfig } from "./config.js";
package/dist/index.js CHANGED
@@ -1,16 +1,21 @@
1
1
  import { createApp } from "./app.js";
2
2
  /**
3
- * Creates the SegmentStream sign-in integration for a Next.js workspace app.
3
+ * Creates the SegmentStream integration for a Next.js workspace app. Pass the type of the app's
4
+ * ontology to type `segmentstream.ontology`:
4
5
  *
5
6
  * ```ts
6
- * export const segmentstream = createSegmentStreamApp({
7
- * workspaceId: "wsp_...",
8
- * organizationId: "org_...",
9
- * });
7
+ * import type ontology from "../../ontology";
8
+ * import link from "../segmentstream.json";
9
+ *
10
+ * export const segmentstream = createSegmentStreamApp<typeof ontology>(link);
10
11
  * ```
12
+ *
13
+ * With `NEON_AUTH_BASE_URL` set, users sign in with Neon Auth and their roles and permissions come
14
+ * from the ontology. Without it, members sign in with their SegmentStream account, as in 0.2.
11
15
  */
12
16
  export function createSegmentStreamApp(config) {
13
17
  return createApp(config);
14
18
  }
15
- export { SegmentStreamApiError } from "./data.js";
19
+ export { NOT_INVITED, SIGN_IN_LINK_INVALID, SegmentStreamApiError } from "./errors.js";
20
+ export { grants, isAllowed } from "./permissions.js";
16
21
  export { sql } from "./parameters.js";
package/dist/me.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ /** The ontology service's account of the caller: `GET /api/ontology/me`. */
2
+ export declare const ME_PATH = "/api/ontology/me";
3
+ /**
4
+ * The signed-in person as the ontology knows them: their `user` record, the roles they hold and the
5
+ * permissions those roles grant, wildcards included.
6
+ */
7
+ export interface CurrentUser {
8
+ /** The ontology `user` record's id: what relationships such as `lead.ownerId` hold. */
9
+ readonly id: string;
10
+ readonly name: string | null;
11
+ readonly email: string | null;
12
+ /** `member` for people. */
13
+ readonly kind: string;
14
+ readonly active: boolean;
15
+ /** The ontology roles the user holds, such as `sales`. */
16
+ readonly roles: ReadonlyArray<string>;
17
+ /** The permissions the roles grant, such as `lead:changeStatus` or `*:read`. */
18
+ readonly permissions: ReadonlyArray<string>;
19
+ }
20
+ /**
21
+ * Reads `{ user: { id, name, email, kind, active }, roles, permissions }` tolerantly: unknown
22
+ * fields are ignored and only what the SDK presents is checked. `null` when it is not that shape.
23
+ */
24
+ export declare function parseMe(payload: unknown): CurrentUser | null;