@segmentstream-cloud/app 0.2.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.
- package/README.md +192 -87
- package/dist/app.d.ts +35 -8
- package/dist/app.js +73 -8
- package/dist/config.d.ts +17 -1
- package/dist/config.js +29 -7
- package/dist/cookies.d.ts +16 -7
- package/dist/cookies.js +36 -47
- package/dist/data.d.ts +6 -18
- package/dist/data.js +17 -36
- package/dist/errors.d.ts +36 -0
- package/dist/errors.js +66 -0
- package/dist/handlers.js +43 -19
- package/dist/index.d.ts +18 -8
- package/dist/index.js +11 -6
- package/dist/me.d.ts +24 -0
- package/dist/me.js +33 -0
- package/dist/neon.d.ts +92 -0
- package/dist/neon.js +414 -0
- package/dist/ontology-transport.d.ts +23 -0
- package/dist/ontology-transport.js +95 -0
- package/dist/ontology.d.ts +217 -0
- package/dist/ontology.js +141 -0
- package/dist/permissions.d.ts +17 -0
- package/dist/permissions.js +33 -0
- package/dist/proxy.d.ts +2 -0
- package/dist/proxy.js +29 -13
- package/dist/return-to.d.ts +2 -0
- package/dist/return-to.js +24 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +35 -5
- package/dist/sign-in.d.ts +5 -1
- package/dist/sign-in.js +17 -4
- package/dist/tokens.d.ts +22 -3
- package/dist/ui/components.d.ts +27 -0
- package/dist/ui/components.js +63 -0
- package/dist/ui/hooks.d.ts +19 -0
- package/dist/ui/hooks.js +56 -0
- package/dist/ui/index.d.ts +10 -0
- package/dist/ui/index.js +8 -0
- package/dist/ui/navigation.d.ts +5 -0
- package/dist/ui/navigation.js +8 -0
- package/dist/ui/sign-in.d.ts +19 -0
- package/dist/ui/sign-in.js +198 -0
- package/dist/ui/store.d.ts +61 -0
- package/dist/ui/store.js +155 -0
- package/dist/ui/styles.css +249 -0
- 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
|
-
|
|
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
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
30
|
-
|
|
31
|
-
:
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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({
|
package/dist/errors.d.ts
ADDED
|
@@ -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 {
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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 "./
|
|
17
|
+
export declare function createSegmentStreamApp<O = unknown>(config: SegmentStreamAppConfig): SegmentStreamApp<O>;
|
|
18
|
+
export { NOT_INVITED, 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
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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 "./
|
|
19
|
+
export { NOT_INVITED, 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;
|
package/dist/me.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** The ontology service's account of the caller: `GET /api/ontology/me`. */
|
|
2
|
+
export const ME_PATH = "/api/ontology/me";
|
|
3
|
+
/**
|
|
4
|
+
* Reads `{ user: { id, name, email, kind, active }, roles, permissions }` tolerantly: unknown
|
|
5
|
+
* fields are ignored and only what the SDK presents is checked. `null` when it is not that shape.
|
|
6
|
+
*/
|
|
7
|
+
export function parseMe(payload) {
|
|
8
|
+
if (typeof payload !== "object" || payload === null) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
const { user, roles, permissions } = payload;
|
|
12
|
+
if (typeof user !== "object" || user === null) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
const { id, name, email, kind, active } = user;
|
|
16
|
+
if (typeof id !== "string" || id === "") {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
id,
|
|
21
|
+
name: typeof name === "string" ? name : null,
|
|
22
|
+
email: typeof email === "string" ? email : null,
|
|
23
|
+
kind: typeof kind === "string" ? kind : "member",
|
|
24
|
+
active: typeof active === "boolean" ? active : true,
|
|
25
|
+
roles: strings(roles),
|
|
26
|
+
permissions: strings(permissions),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function strings(value) {
|
|
30
|
+
return Array.isArray(value)
|
|
31
|
+
? value.filter((item) => typeof item === "string")
|
|
32
|
+
: [];
|
|
33
|
+
}
|