@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.
Files changed (47) hide show
  1. package/README.md +192 -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 +36 -0
  11. package/dist/errors.js +66 -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 +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/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 +19 -0
  43. package/dist/ui/sign-in.js +198 -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
@@ -0,0 +1,217 @@
1
+ /**
2
+ * The typed client for the app's ontology service, `/api/ontology`. The app imports its ontology as
3
+ * types only (`import type ontology from "../../ontology"`) and passes `typeof ontology` to
4
+ * `createSegmentStreamApp`; entity records, action inputs and action results are read from the
5
+ * ontology SDK's `Ontology`, `Entity` and `Action` types. The types are read structurally, so the
6
+ * app needs neither the ontology SDK nor Effect at runtime, and `app/` and `ontology/` may install
7
+ * their own copies.
8
+ */
9
+ /** JSON as the ontology service sends and accepts it. */
10
+ export type JsonValue = string | number | boolean | null | ReadonlyArray<JsonValue> | {
11
+ readonly [key: string]: JsonValue;
12
+ };
13
+ /** A record of an entity the client has no types for. */
14
+ export type JsonRecord = {
15
+ readonly [field: string]: JsonValue;
16
+ };
17
+ /**
18
+ * Conditions on one field; all of them must hold. `contains` and `startsWith` ignore case;
19
+ * `between` includes both ends. In a `where`, a bare value stands for `{ eq: value }`.
20
+ */
21
+ export interface FieldFilter {
22
+ readonly eq?: JsonValue;
23
+ readonly ne?: JsonValue;
24
+ readonly in?: ReadonlyArray<JsonValue>;
25
+ readonly isNull?: boolean;
26
+ readonly contains?: string;
27
+ readonly startsWith?: string;
28
+ readonly gt?: number | string;
29
+ readonly gte?: number | string;
30
+ readonly lt?: number | string;
31
+ readonly lte?: number | string;
32
+ readonly between?: readonly [number | string, number | string];
33
+ }
34
+ /** A filter on a to-many relationship: some, none or every related record matches. */
35
+ export interface ManyFilter {
36
+ readonly some?: Where;
37
+ readonly none?: Where;
38
+ readonly every?: Where;
39
+ }
40
+ /** Conditions on a record by field or relationship name, with `and`, `or` and `not`. */
41
+ export interface Where {
42
+ readonly and?: ReadonlyArray<Where>;
43
+ readonly or?: ReadonlyArray<Where>;
44
+ readonly not?: Where;
45
+ readonly [field: string]: FieldFilter | ManyFilter | Where | ReadonlyArray<Where> | JsonValue | undefined;
46
+ }
47
+ export interface OrderBy<Field extends string = string> {
48
+ readonly field: Field;
49
+ readonly direction?: "asc" | "desc";
50
+ }
51
+ /** A query: the same language for `find` (as URL parameters) and `query` (as a JSON body). */
52
+ export interface OntologyQuery<Row = JsonRecord> {
53
+ readonly where?: Where;
54
+ readonly orderBy?: ReadonlyArray<OrderBy<keyof Row & string>>;
55
+ /** Related records to add to each record, by relationship name. */
56
+ readonly include?: {
57
+ readonly [relationship: string]: true;
58
+ };
59
+ /** The `nextCursor` of the previous page. */
60
+ readonly after?: string;
61
+ /** Records per page, 50 by default and at most 200. */
62
+ readonly limit?: number;
63
+ }
64
+ /** A page of records. `nextCursor` is `null` on the last page. */
65
+ export interface OntologyPage<Row> {
66
+ readonly items: ReadonlyArray<Row>;
67
+ readonly nextCursor: string | null;
68
+ }
69
+ export type EditKind = "create" | "update" | "delete" | "link" | "unlink";
70
+ export interface Change {
71
+ readonly before: JsonValue;
72
+ readonly after: JsonValue;
73
+ }
74
+ /** What one action changed on one record. */
75
+ export interface Edit {
76
+ readonly entity: string;
77
+ readonly id: string;
78
+ readonly kind: EditKind;
79
+ readonly changes: {
80
+ readonly [field: string]: Change;
81
+ };
82
+ }
83
+ export interface HistoryEntry {
84
+ readonly id: string;
85
+ readonly recordId: string;
86
+ /** The qualified action, for example `lead.changeStatus`. */
87
+ readonly action: string;
88
+ /** Shared by every entry one run of an action wrote. */
89
+ readonly actionId: string;
90
+ readonly kind: EditKind;
91
+ /** The user who ran the action; `null` when the platform wrote. */
92
+ readonly principalId: string | null;
93
+ readonly at: string;
94
+ readonly changes: {
95
+ readonly [field: string]: Change;
96
+ };
97
+ }
98
+ export interface HistoryPage {
99
+ readonly items: ReadonlyArray<HistoryEntry>;
100
+ readonly nextCursor: string | null;
101
+ }
102
+ export interface ValidationIssue {
103
+ readonly path: ReadonlyArray<string | number>;
104
+ readonly message: string;
105
+ }
106
+ /** `POST /api/ontology/<entity>/actions/<action>` answers `{ result, validation, edits }`. */
107
+ export interface ActionResponse<Result = unknown> {
108
+ /** What the action returned; `null` under `validateOnly` and for actions without a result. */
109
+ readonly result: Result | null;
110
+ readonly validation: {
111
+ readonly ok: boolean;
112
+ readonly issues: ReadonlyArray<ValidationIssue>;
113
+ };
114
+ /** The records the action changed, as their history records each change. */
115
+ readonly edits: ReadonlyArray<Edit>;
116
+ }
117
+ export interface RequestOptions {
118
+ readonly signal?: AbortSignal;
119
+ }
120
+ export interface ActionOptions extends RequestOptions {
121
+ /** Validate the input and report issues in `validation` without a write. */
122
+ readonly validateOnly?: boolean;
123
+ }
124
+ export interface HistoryOptions extends RequestOptions {
125
+ readonly after?: string;
126
+ /** Entries per page, at most 200. */
127
+ readonly limit?: number;
128
+ }
129
+ /** Runs one action: `lead.actions.changeStatus({ lead, status }, { validateOnly })`. */
130
+ export type ActionCall<Input, Result> = (input: Input, options?: ActionOptions) => Promise<ActionResponse<Result>>;
131
+ /** The generated actions every entity has; their inputs are not typed by the client. */
132
+ export interface GeneratedActions {
133
+ readonly create: ActionCall<JsonRecord, JsonValue>;
134
+ readonly update: ActionCall<{
135
+ readonly id: string;
136
+ } & JsonRecord, JsonValue>;
137
+ readonly delete: ActionCall<{
138
+ readonly id: string;
139
+ }, JsonValue>;
140
+ }
141
+ export interface EntityClient<Row, Actions> {
142
+ /** `GET /api/ontology/<entity>`: filter, sort and page through records. */
143
+ readonly find: (query?: OntologyQuery<Row>, options?: RequestOptions) => Promise<OntologyPage<Row>>;
144
+ /** `POST /api/ontology/<entity>/query`: the same query as a JSON body. */
145
+ readonly query: (query?: OntologyQuery<Row>, options?: RequestOptions) => Promise<OntologyPage<Row>>;
146
+ /** `GET /api/ontology/<entity>/<id>`. */
147
+ readonly get: (id: string, options?: RequestOptions) => Promise<Row>;
148
+ /** `GET /api/ontology/<entity>/<id>/history`: what each action changed, newest first. */
149
+ readonly history: (id: string, options?: HistoryOptions) => Promise<HistoryPage>;
150
+ /** The entity's actions by name, authored and generated. */
151
+ readonly actions: Actions & GeneratedActions;
152
+ /** Any action by name, for generated actions such as `upsertByEmail`, `link` and `unlink`. */
153
+ readonly action: (name: string, input: JsonRecord, options?: ActionOptions) => Promise<ActionResponse<JsonValue>>;
154
+ }
155
+ type EntitiesOf<O> = O extends {
156
+ readonly config: {
157
+ readonly entities: ReadonlyArray<infer E>;
158
+ };
159
+ } ? E : never;
160
+ type NameOf<E> = E extends {
161
+ readonly name: infer N extends string;
162
+ } ? N : never;
163
+ type RecordOf<E> = E extends {
164
+ readonly Type: infer T;
165
+ } ? T : JsonRecord;
166
+ type ActionsOf<E> = E extends {
167
+ readonly actions: infer A;
168
+ } ? A : never;
169
+ type InputOf<A> = A extends {
170
+ readonly input: {
171
+ readonly Encoded: infer I;
172
+ };
173
+ } ? I : JsonRecord;
174
+ /** An Effect yields its success when iterated, as `yield*` in `Effect.gen` does. */
175
+ type SuccessOf<F> = F extends {
176
+ [Symbol.iterator](): {
177
+ next(...args: ReadonlyArray<any>): IteratorResult<unknown, infer R>;
178
+ };
179
+ } ? R : unknown;
180
+ /** Actions without a result answer `result: null`. */
181
+ type JsonResult<R> = [R] extends [void] ? null : R;
182
+ type ResultOf<A> = A extends {
183
+ handler(context: never): infer F;
184
+ } ? JsonResult<SuccessOf<F>> : unknown;
185
+ type ActionClients<A> = {
186
+ readonly [K in keyof A & string]: ActionCall<InputOf<A[K]>, ResultOf<A[K]>>;
187
+ };
188
+ type TypedOntologyClient<O> = {
189
+ readonly [N in NameOf<EntitiesOf<O>>]: EntityClient<RecordOf<Extract<EntitiesOf<O>, {
190
+ readonly name: N;
191
+ }>>, ActionClients<ActionsOf<Extract<EntitiesOf<O>, {
192
+ readonly name: N;
193
+ }>>>>;
194
+ };
195
+ /** A client for any entity, when the app passes no ontology type. */
196
+ export type UntypedOntologyClient = {
197
+ readonly [entity: string]: EntityClient<JsonRecord, {
198
+ readonly [action: string]: ActionCall<JsonRecord, JsonValue>;
199
+ }>;
200
+ };
201
+ /**
202
+ * `segmentstream.ontology`: one client per entity of `O`, the type of the app's ontology
203
+ * (`typeof ontology`). Without it, any entity name is accepted and records are untyped.
204
+ */
205
+ export type OntologyClient<O = unknown> = unknown extends O ? UntypedOntologyClient : TypedOntologyClient<O>;
206
+ /** Sends one ontology request with the caller's credentials. Paths are below `/api/ontology`. */
207
+ export interface OntologyTransport {
208
+ readonly fetch: (path: string, init: RequestInit) => Promise<Response>;
209
+ }
210
+ /**
211
+ * Entities and actions exist only in the app's types, so the client resolves them by name when
212
+ * they are used: `ontology.lead.actions.changeStatus(…)` posts to `lead/actions/changeStatus`.
213
+ */
214
+ export declare function createOntologyClient<O>(transport: OntologyTransport): OntologyClient<O>;
215
+ /** `find` sends the query as URL parameters: `where` as JSON, `orderBy` as `field:direction`. */
216
+ export declare function findParameters(query: OntologyQuery<JsonRecord>): URLSearchParams;
217
+ export {};
@@ -0,0 +1,141 @@
1
+ import { SegmentStreamApiError, apiErrorFrom, readJson } from "./errors.js";
2
+ const ENTITY_NAME = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/;
3
+ const ACTION_NAME = /^[a-z][a-zA-Z0-9]*$/;
4
+ /** Names a caller may probe on any object; never an entity or an action. */
5
+ const RESERVED = new Set([
6
+ "then",
7
+ "catch",
8
+ "finally",
9
+ "constructor",
10
+ "toString",
11
+ "toLocaleString",
12
+ "valueOf",
13
+ "hasOwnProperty",
14
+ "isPrototypeOf",
15
+ "propertyIsEnumerable",
16
+ "inspect",
17
+ ]);
18
+ /**
19
+ * Entities and actions exist only in the app's types, so the client resolves them by name when
20
+ * they are used: `ontology.lead.actions.changeStatus(…)` posts to `lead/actions/changeStatus`.
21
+ */
22
+ export function createOntologyClient(transport) {
23
+ const entities = new Map();
24
+ return new Proxy(Object.create(null), {
25
+ get(_target, property) {
26
+ if (typeof property !== "string" || RESERVED.has(property) || !ENTITY_NAME.test(property)) {
27
+ return undefined;
28
+ }
29
+ let client = entities.get(property);
30
+ if (client === undefined) {
31
+ client = entityClient(property, transport);
32
+ entities.set(property, client);
33
+ }
34
+ return client;
35
+ },
36
+ });
37
+ }
38
+ function entityClient(entity, transport) {
39
+ const base = `/${entity}`;
40
+ const actionCalls = new Map();
41
+ const runAction = (name, input, options = {}) => send(transport, "POST", `${base}/actions/${encodeURIComponent(name)}`, {
42
+ input,
43
+ ...(options.validateOnly === undefined
44
+ ? {}
45
+ : { options: { validateOnly: options.validateOnly } }),
46
+ }, options.signal);
47
+ const actions = new Proxy(Object.create(null), {
48
+ get(_target, property) {
49
+ if (typeof property !== "string" || RESERVED.has(property) || !ACTION_NAME.test(property)) {
50
+ return undefined;
51
+ }
52
+ let call = actionCalls.get(property);
53
+ if (call === undefined) {
54
+ call = (input, options) => runAction(property, input, options);
55
+ actionCalls.set(property, call);
56
+ }
57
+ return call;
58
+ },
59
+ });
60
+ return {
61
+ find: (query = {}, options = {}) => {
62
+ const search = findParameters(query).toString();
63
+ return send(transport, "GET", search === "" ? base : `${base}?${search}`, undefined, options.signal);
64
+ },
65
+ query: (query = {}, options = {}) => send(transport, "POST", `${base}/query`, query, options.signal),
66
+ get: (id, options = {}) => send(transport, "GET", `${base}/${recordId(id)}`, undefined, options.signal),
67
+ history: (id, options = {}) => {
68
+ const search = new URLSearchParams();
69
+ if (options.after !== undefined) {
70
+ search.set("after", options.after);
71
+ }
72
+ if (options.limit !== undefined) {
73
+ search.set("limit", String(options.limit));
74
+ }
75
+ const encoded = search.toString();
76
+ const suffix = encoded === "" ? "" : `?${encoded}`;
77
+ return send(transport, "GET", `${base}/${recordId(id)}/history${suffix}`, undefined, options.signal);
78
+ },
79
+ actions,
80
+ action: (name, input, options) => {
81
+ if (!ACTION_NAME.test(name)) {
82
+ throw new TypeError(`"${name}" is not an action name.`);
83
+ }
84
+ return runAction(name, input, options);
85
+ },
86
+ };
87
+ }
88
+ function recordId(id) {
89
+ if (typeof id !== "string" || id === "") {
90
+ throw new TypeError("A record id is a non-empty string.");
91
+ }
92
+ return encodeURIComponent(id);
93
+ }
94
+ /** `find` sends the query as URL parameters: `where` as JSON, `orderBy` as `field:direction`. */
95
+ export function findParameters(query) {
96
+ const search = new URLSearchParams();
97
+ if (query.where !== undefined) {
98
+ search.set("where", JSON.stringify(query.where));
99
+ }
100
+ if (query.orderBy !== undefined && query.orderBy.length > 0) {
101
+ search.set("orderBy", query.orderBy.map(({ field, direction }) => `${field}:${direction ?? "asc"}`).join(","));
102
+ }
103
+ if (query.include !== undefined) {
104
+ const relationships = Object.keys(query.include).filter((name) => query.include?.[name] === true);
105
+ if (relationships.length > 0) {
106
+ search.set("include", relationships.join(","));
107
+ }
108
+ }
109
+ if (query.after !== undefined) {
110
+ search.set("after", query.after);
111
+ }
112
+ if (query.limit !== undefined) {
113
+ search.set("limit", String(query.limit));
114
+ }
115
+ return search;
116
+ }
117
+ async function send(transport, method, path, body, signal) {
118
+ const headers = new Headers({ accept: "application/json" });
119
+ if (body !== undefined) {
120
+ headers.set("content-type", "application/json");
121
+ }
122
+ const response = await transport.fetch(path, {
123
+ method,
124
+ headers,
125
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
126
+ ...(signal === undefined ? {} : { signal }),
127
+ });
128
+ const payload = await readJson(response);
129
+ if (!response.ok) {
130
+ throw apiErrorFrom(response.status, payload, "The ontology service rejected the request.");
131
+ }
132
+ if (payload === null) {
133
+ throw new SegmentStreamApiError({
134
+ code: "ontology_invalid_response",
135
+ status: 502,
136
+ detail: "The ontology service answered without a JSON body.",
137
+ retryable: true,
138
+ });
139
+ }
140
+ return payload;
141
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Permissions are `<entity>:<verb>` strings, such as `lead:changeStatus`. A role may use `*` for the
3
+ * entity (`*:read`), the verb (`lead:*`) or both (`*:*`); what an action or a read requires never
4
+ * holds a wildcard.
5
+ *
6
+ * The matching rule is the ontology service's, copied from `@segmentstream-cloud/ontology`
7
+ * (`src/permissions.ts`) so that the app SDK and its browser components do not depend on the
8
+ * ontology runtime. Keep the two in step.
9
+ */
10
+ /** Whether a granted permission, which may hold wildcards, covers the required one. */
11
+ export declare function grants(granted: string, required: string): boolean;
12
+ /** Anything that holds permissions, such as the signed-in user. */
13
+ export interface PermissionHolder {
14
+ readonly permissions: ReadonlyArray<string>;
15
+ }
16
+ /** Whether one of the holder's permissions equals `required` or covers it through a wildcard. */
17
+ export declare function isAllowed(required: string, holder: PermissionHolder): boolean;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Permissions are `<entity>:<verb>` strings, such as `lead:changeStatus`. A role may use `*` for the
3
+ * entity (`*:read`), the verb (`lead:*`) or both (`*:*`); what an action or a read requires never
4
+ * holds a wildcard.
5
+ *
6
+ * The matching rule is the ontology service's, copied from `@segmentstream-cloud/ontology`
7
+ * (`src/permissions.ts`) so that the app SDK and its browser components do not depend on the
8
+ * ontology runtime. Keep the two in step.
9
+ */
10
+ const WILDCARD = "*";
11
+ /** `<entity>:<verb>` with lowercase snake_case entities and lower camelCase verbs, or `*` for either. */
12
+ const PERMISSION = /^(\*|[a-z][a-z0-9]*(?:_[a-z0-9]+)*):(\*|[a-z][a-zA-Z0-9]*)$/;
13
+ function parsePermission(value) {
14
+ if (typeof value !== "string") {
15
+ return undefined;
16
+ }
17
+ const match = PERMISSION.exec(value);
18
+ return match === null ? undefined : { entity: match[1], verb: match[2] };
19
+ }
20
+ /** Whether a granted permission, which may hold wildcards, covers the required one. */
21
+ export function grants(granted, required) {
22
+ const held = parsePermission(granted);
23
+ const wanted = parsePermission(required);
24
+ if (held === undefined || wanted === undefined) {
25
+ return false;
26
+ }
27
+ return ((held.entity === WILDCARD || held.entity === wanted.entity) &&
28
+ (held.verb === WILDCARD || held.verb === wanted.verb));
29
+ }
30
+ /** Whether one of the holder's permissions equals `required` or covers it through a wildcard. */
31
+ export function isAllowed(required, holder) {
32
+ return holder.permissions.some((granted) => grants(granted, required));
33
+ }
package/dist/proxy.d.ts CHANGED
@@ -11,3 +11,5 @@ export interface ProxyOptions {
11
11
  }
12
12
  export type SegmentStreamProxy = (request: NextRequest) => Promise<NextResponse>;
13
13
  export declare function createProxy(context: AppContext, options?: ProxyOptions): SegmentStreamProxy;
14
+ export declare function normalizePublicPath(path: string): string;
15
+ export declare function matchesPathPrefix(pathname: string, prefix: string): boolean;
package/dist/proxy.js CHANGED
@@ -4,13 +4,29 @@ import { clearSessionCookies, cookiePolicyFor, replaceRequestCookies, safeReturn
4
4
  import { SDK_ROUTE_PREFIX, isApiRequest, signInRedirect, unauthenticatedResponse, } from "./sign-in.js";
5
5
  export function createProxy(context, options = {}) {
6
6
  const publicPaths = (options.publicPaths ?? []).map(normalizePublicPath);
7
+ const refreshes = new Map();
8
+ /**
9
+ * A page load and the prefetches it triggers arrive together with the same expired
10
+ * session. They share one refresh instead of each spending the refresh token.
11
+ */
12
+ function refreshSession(refreshToken) {
13
+ const pending = refreshes.get(refreshToken);
14
+ if (pending !== undefined) {
15
+ return pending;
16
+ }
17
+ const exchange = context
18
+ .requestToken({ grantType: "refresh_token", refreshToken })
19
+ .finally(() => refreshes.delete(refreshToken));
20
+ refreshes.set(refreshToken, exchange);
21
+ return exchange;
22
+ }
7
23
  return async function proxy(request) {
8
24
  const pathname = request.nextUrl.pathname;
9
25
  if (matchesPathPrefix(pathname, SDK_ROUTE_PREFIX) ||
10
26
  publicPaths.some((path) => matchesPathPrefix(pathname, path))) {
11
27
  return NextResponse.next();
12
28
  }
13
- const policy = cookiePolicyFor(request.nextUrl);
29
+ const policy = cookiePolicyFor(request.nextUrl, context.config.workspaceId);
14
30
  const accessToken = request.cookies.get(policy.names.session)?.value;
15
31
  if (accessToken !== undefined && accessToken !== "") {
16
32
  const verification = await context.verifyToken(accessToken);
@@ -20,14 +36,14 @@ export function createProxy(context, options = {}) {
20
36
  }
21
37
  const refreshToken = request.cookies.get(policy.names.refresh)?.value;
22
38
  if (refreshToken === undefined || refreshToken === "") {
23
- return startSignIn(context, request, accessToken !== undefined);
39
+ return startSignIn(context, request, policy, accessToken !== undefined);
24
40
  }
25
- const exchange = await context.requestToken({ grantType: "refresh_token", refreshToken });
41
+ const exchange = await refreshSession(refreshToken);
26
42
  if (exchange.status === "rejected") {
27
- return startSignIn(context, request, true);
43
+ return startSignIn(context, request, policy, true);
28
44
  }
29
45
  if (exchange.status === "unavailable") {
30
- return sessionUnavailable(context, request);
46
+ return sessionUnavailable(context, request, policy);
31
47
  }
32
48
  const verification = await context.verifyToken(exchange.tokens.accessToken);
33
49
  if (verification.status === "valid") {
@@ -35,28 +51,28 @@ export function createProxy(context, options = {}) {
35
51
  }
36
52
  if (verification.status === "unavailable") {
37
53
  // The refresh token was rotated; keep the new one even though this request cannot proceed.
38
- const response = await sessionUnavailable(context, request);
54
+ const response = await sessionUnavailable(context, request, policy);
39
55
  setSessionCookies(response, policy, exchange.tokens);
40
56
  return response;
41
57
  }
42
- return startSignIn(context, request, true);
58
+ return startSignIn(context, request, policy, true);
43
59
  };
44
60
  }
45
61
  /**
46
62
  * SegmentStream is unreachable. API requests get a retryable 503; pages restart sign-in,
47
63
  * which ends on the callback's error page rather than looping. Session cookies are kept.
48
64
  */
49
- async function sessionUnavailable(context, request) {
65
+ async function sessionUnavailable(context, request, policy) {
50
66
  if (isApiRequest(request)) {
51
67
  return NextResponse.json({ error: "session_refresh_unavailable" }, { status: 503, headers: { "cache-control": "no-store", "retry-after": "5" } });
52
68
  }
53
- return startSignIn(context, request, false);
69
+ return startSignIn(context, request, policy, false);
54
70
  }
55
- async function startSignIn(context, request, clearSession) {
71
+ async function startSignIn(context, request, policy, clearSession) {
56
72
  if (isApiRequest(request)) {
57
73
  const response = unauthenticatedResponse();
58
74
  if (clearSession) {
59
- clearSessionCookies(response, cookiePolicyFor(request.nextUrl));
75
+ clearSessionCookies(response, policy);
60
76
  }
61
77
  return response;
62
78
  }
@@ -78,12 +94,12 @@ function continueWithSession(request, policy, tokens) {
78
94
  setSessionCookies(response, policy, tokens);
79
95
  return response;
80
96
  }
81
- function normalizePublicPath(path) {
97
+ export function normalizePublicPath(path) {
82
98
  const withLeadingSlash = path.startsWith("/") ? path : `/${path}`;
83
99
  const trimmed = trimTrailingSlashes(withLeadingSlash);
84
100
  return trimmed === "" ? "/" : trimmed;
85
101
  }
86
- function matchesPathPrefix(pathname, prefix) {
102
+ export function matchesPathPrefix(pathname, prefix) {
87
103
  if (prefix === "/") {
88
104
  return true;
89
105
  }
@@ -0,0 +1,2 @@
1
+ /** Reduces a return path to a same-origin relative path, falling back to `/`. */
2
+ export declare function safeReturnTo(value: string | null | undefined): string;
@@ -0,0 +1,24 @@
1
+ /** Reduces a return path to a same-origin relative path, falling back to `/`. */
2
+ export function safeReturnTo(value) {
3
+ if (typeof value !== "string" ||
4
+ !value.startsWith("/") ||
5
+ value.startsWith("//") ||
6
+ value.startsWith("/\\")) {
7
+ return "/";
8
+ }
9
+ if (/[\u0000-\u001f\u007f]/.test(value)) {
10
+ return "/";
11
+ }
12
+ const placeholderOrigin = "https://return-to.invalid";
13
+ let url;
14
+ try {
15
+ url = new URL(value, placeholderOrigin);
16
+ }
17
+ catch {
18
+ return "/";
19
+ }
20
+ if (url.origin !== placeholderOrigin) {
21
+ return "/";
22
+ }
23
+ return `${url.pathname}${url.search}${url.hash}`;
24
+ }
package/dist/server.d.ts CHANGED
@@ -4,5 +4,8 @@ export interface ServerSession {
4
4
  readonly getUser: () => Promise<AppUser | null>;
5
5
  readonly requireUser: () => Promise<AppUser>;
6
6
  readonly getAccessToken: () => Promise<string | null>;
7
+ readonly signOut: (options?: {
8
+ readonly redirectTo?: string;
9
+ }) => Promise<never>;
7
10
  }
8
11
  export declare function createServerSession(context: AppContext): ServerSession;
package/dist/server.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import { cookies, headers } from "next/headers";
2
2
  import { redirect } from "next/navigation";
3
- import { sessionCookieCandidates } from "./cookies.js";
4
- import { SIGN_IN_PATH } from "./sign-in.js";
3
+ import { cookiePolicyFor, isLoopbackHostname, safeReturnTo, sessionCookieCandidates, } from "./cookies.js";
4
+ import { SIGNED_OUT_PATH, SIGN_IN_PATH } from "./sign-in.js";
5
5
  export function createServerSession(context) {
6
6
  async function verifiedSession() {
7
7
  const cookieStore = await cookies();
8
8
  const headerStore = await headers();
9
9
  const hostname = hostnameOf(headerStore.get("host"));
10
- for (const name of sessionCookieCandidates(hostname)) {
10
+ for (const name of sessionCookieCandidates(hostname, context.config.workspaceId)) {
11
11
  const token = cookieStore.get(name)?.value;
12
12
  if (token === undefined || token === "") {
13
13
  continue;
@@ -21,7 +21,8 @@ export function createServerSession(context) {
21
21
  }
22
22
  async function getUser() {
23
23
  const session = await verifiedSession();
24
- return session?.user ?? null;
24
+ // Ontology roles come with Neon Auth; the legacy sign-in has none.
25
+ return session === null ? null : { ...session.user, roles: [], permissions: [] };
25
26
  }
26
27
  async function requireUser() {
27
28
  const user = await getUser();
@@ -35,7 +36,36 @@ export function createServerSession(context) {
35
36
  const session = await verifiedSession();
36
37
  return session?.token ?? null;
37
38
  }
38
- return { getUser, requireUser, getAccessToken };
39
+ /** Revokes the app session and clears its cookies, as `POST /api/segmentstream/sign-out` does. */
40
+ async function signOut(options = {}) {
41
+ const cookieStore = await cookies();
42
+ const hostname = hostnameOf((await headers()).get("host"));
43
+ const policies = [
44
+ cookiePolicyFor(new URL("https://app.invalid"), context.config.workspaceId),
45
+ ...(isLoopbackHostname(hostname)
46
+ ? [cookiePolicyFor(new URL("http://localhost"), context.config.workspaceId)]
47
+ : []),
48
+ ];
49
+ for (const policy of policies) {
50
+ const refreshToken = cookieStore.get(policy.names.refresh)?.value;
51
+ if (refreshToken !== undefined && refreshToken !== "") {
52
+ await context.revokeSession(refreshToken);
53
+ }
54
+ for (const name of [policy.names.session, policy.names.refresh]) {
55
+ // `__Host-` cookies are only replaced when the clearing cookie repeats Secure and Path=/.
56
+ cookieStore.set(name, "", {
57
+ httpOnly: true,
58
+ secure: policy.secure,
59
+ sameSite: "lax",
60
+ path: "/",
61
+ maxAge: 0,
62
+ expires: new Date(0),
63
+ });
64
+ }
65
+ }
66
+ redirect(safeReturnTo(options.redirectTo ?? SIGNED_OUT_PATH));
67
+ }
68
+ return { getUser, requireUser, getAccessToken, signOut };
39
69
  }
40
70
  function hostnameOf(host) {
41
71
  if (host === null || host === "") {
package/dist/sign-in.d.ts CHANGED
@@ -17,7 +17,11 @@ export interface SignInRedirectInput {
17
17
  readonly returnTo: string;
18
18
  readonly clearSession: boolean;
19
19
  }
20
- /** Starts the authorization-code flow with PKCE by redirecting to SegmentStream. */
20
+ /**
21
+ * Starts the authorization-code flow with PKCE by redirecting to SegmentStream. Each
22
+ * sign-in keeps its flow in a cookie named after its `state`, so tabs that start
23
+ * signing in at the same time do not overwrite each other.
24
+ */
21
25
  export declare function signInRedirect(input: SignInRedirectInput): Promise<NextResponse>;
22
26
  export interface AuthorizationUrlInput {
23
27
  readonly config: ResolvedConfig;