@segmentstream-cloud/app 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +230 -47
  2. package/dist/app.d.ts +42 -5
  3. package/dist/app.js +81 -7
  4. package/dist/config.d.ts +17 -1
  5. package/dist/config.js +29 -7
  6. package/dist/cookies.d.ts +16 -7
  7. package/dist/cookies.js +36 -47
  8. package/dist/data.d.ts +126 -0
  9. package/dist/data.js +206 -0
  10. package/dist/errors.d.ts +36 -0
  11. package/dist/errors.js +66 -0
  12. package/dist/handlers.js +43 -19
  13. package/dist/index.d.ts +21 -7
  14. package/dist/index.js +12 -5
  15. package/dist/me.d.ts +24 -0
  16. package/dist/me.js +33 -0
  17. package/dist/neon.d.ts +92 -0
  18. package/dist/neon.js +414 -0
  19. package/dist/ontology-transport.d.ts +23 -0
  20. package/dist/ontology-transport.js +95 -0
  21. package/dist/ontology.d.ts +217 -0
  22. package/dist/ontology.js +141 -0
  23. package/dist/parameters.d.ts +55 -0
  24. package/dist/parameters.js +118 -0
  25. package/dist/permissions.d.ts +17 -0
  26. package/dist/permissions.js +33 -0
  27. package/dist/proxy.d.ts +2 -0
  28. package/dist/proxy.js +29 -13
  29. package/dist/return-to.d.ts +2 -0
  30. package/dist/return-to.js +24 -0
  31. package/dist/server.d.ts +3 -0
  32. package/dist/server.js +35 -5
  33. package/dist/sign-in.d.ts +5 -1
  34. package/dist/sign-in.js +17 -4
  35. package/dist/tokens.d.ts +22 -3
  36. package/dist/ui/components.d.ts +27 -0
  37. package/dist/ui/components.js +63 -0
  38. package/dist/ui/hooks.d.ts +19 -0
  39. package/dist/ui/hooks.js +56 -0
  40. package/dist/ui/index.d.ts +10 -0
  41. package/dist/ui/index.js +8 -0
  42. package/dist/ui/navigation.d.ts +5 -0
  43. package/dist/ui/navigation.js +8 -0
  44. package/dist/ui/sign-in.d.ts +19 -0
  45. package/dist/ui/sign-in.js +198 -0
  46. package/dist/ui/store.d.ts +61 -0
  47. package/dist/ui/store.js +155 -0
  48. package/dist/ui/styles.css +249 -0
  49. package/package.json +24 -7
package/README.md CHANGED
@@ -1,8 +1,11 @@
1
1
  # @segmentstream-cloud/app
2
2
 
3
- Sign-in and sessions for SegmentStream workspace apps built with Next.js (App Router,
4
- version 16 or later). Every visitor signs in with their SegmentStream account, and only
5
- members of the app's workspace get in.
3
+ Sign-in, users, permissions, the ontology client and warehouse data access for
4
+ SegmentStream workspace apps built with Next.js (App Router, version 16 or later) and
5
+ React 19. The app's users sign in with an email code through Neon Auth (Managed
6
+ Better Auth) on the workspace's Neon project; the workspace's ontology decides who may
7
+ use the app and what they may do. The SDK wraps `@neondatabase/auth`, so the app never
8
+ configures Neon Auth itself.
6
9
 
7
10
  ## Install
8
11
 
@@ -10,17 +13,18 @@ members of the app's workspace get in.
10
13
  npm install @segmentstream-cloud/app
11
14
  ```
12
15
 
13
- ## Add three files
16
+ ## Set up
14
17
 
15
- **`src/segmentstream.ts`** configures the app once:
18
+ **`src/segmentstream.ts`** configures the app once. Pass the type of the app's
19
+ ontology to type `segmentstream.ontology`; the ontology is imported as a type only:
16
20
 
17
21
  ```ts
18
22
  import { createSegmentStreamApp } from "@segmentstream-cloud/app";
19
23
 
20
- export const segmentstream = createSegmentStreamApp({
21
- workspaceId: "your-workspace-id",
22
- organizationId: "your-organization-id",
23
- });
24
+ import type ontology from "../../ontology";
25
+ import link from "../segmentstream.json"; // { "workspaceId": "…", "organizationId": "…" }
26
+
27
+ export const segmentstream = createSegmentStreamApp<typeof ontology>(link);
24
28
  ```
25
29
 
26
30
  **`src/proxy.ts`** keeps every page and API route signed in:
@@ -29,22 +33,49 @@ export const segmentstream = createSegmentStreamApp({
29
33
  import { segmentstream } from "./segmentstream";
30
34
 
31
35
  export const proxy = segmentstream.proxy({
32
- // Optional: paths that anyone may open. `/health` also covers `/health/live`.
33
- publicPaths: ["/health"],
36
+ // Optional: paths anyone may open. `/webhooks` also covers `/webhooks/stripe`.
37
+ publicPaths: ["/webhooks"],
34
38
  });
35
39
 
36
40
  export const config = {
37
- matcher: ["/((?!_next/static|_next/image|favicon.ico|robots.txt).*)"],
41
+ matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
38
42
  };
39
43
  ```
40
44
 
41
- **`src/app/api/segmentstream/[...path]/route.ts`** handles the sign-in callback and
42
- sign-out:
45
+ Static assets, `/api/auth`, `/sign-in` and `/api/health` are always open.
46
+
47
+ **`src/app/api/auth/[...path]/route.ts`** serves the Neon Auth API that sign-in and
48
+ sessions use:
43
49
 
44
50
  ```ts
45
- import { segmentstream } from "../../../../segmentstream";
51
+ import { segmentstream } from "@/segmentstream";
52
+
53
+ export const { GET, POST, PUT, DELETE, PATCH } = segmentstream.authHandlers();
54
+ ```
55
+
56
+ **`src/app/sign-in/page.tsx`** renders the sign-in form, and the root layout imports
57
+ its stylesheet once:
58
+
59
+ ```tsx
60
+ import { SignIn } from "@segmentstream-cloud/app/ui";
61
+
62
+ type SearchParams = Promise<Record<string, string | string[] | undefined>>;
63
+
64
+ export default async function SignInPage({ searchParams }: { searchParams: SearchParams }) {
65
+ const { returnTo, error } = await searchParams;
46
66
 
47
- export const { GET, POST } = segmentstream.handlers();
67
+ return (
68
+ <SignIn
69
+ returnTo={typeof returnTo === "string" ? returnTo : undefined}
70
+ error={typeof error === "string" ? error : undefined}
71
+ />
72
+ );
73
+ }
74
+ ```
75
+
76
+ ```tsx
77
+ // src/app/layout.tsx
78
+ import "@segmentstream-cloud/app/ui/styles.css";
48
79
  ```
49
80
 
50
81
  ## Read the signed-in user
@@ -55,58 +86,210 @@ import { segmentstream } from "@/segmentstream";
55
86
  export default async function Page() {
56
87
  const user = await segmentstream.requireUser();
57
88
 
58
- return <p>Signed in as {user.email ?? user.id}</p>;
89
+ return (
90
+ <p>
91
+ {user.name ?? user.email}: {user.roles.join(", ")}
92
+ </p>
93
+ );
59
94
  }
60
95
  ```
61
96
 
62
- - `getUser()` returns `{ id, email?, name?, organizationId, workspaceId }` or `null`.
63
- - `requireUser()` returns the user, or redirects to `/api/segmentstream/sign-in` when
64
- there is none. Next.js `unauthorized()` needs the experimental `authInterrupts` flag,
65
- so the SDK restarts sign-in instead. The proxy normally signs users in first, so this
66
- only happens on paths the proxy matcher excludes.
67
- - `getAccessToken()` returns the verified app session token for calls to SegmentStream.
68
-
69
- Use them in Server Components, Route Handlers, and Server Functions. Always check the
97
+ - `requireUser()` returns `{ id, email?, name?, roles, permissions, kind?, active?,
98
+ authUserId?, organizationId, workspaceId }`. `id` is the person's ontology `user`
99
+ record. Roles and permissions come from the ontology's `GET /api/ontology/me`,
100
+ cached per session for a minute. Signed out, it redirects to `/sign-in`. For a
101
+ person the app has not invited (`403 not_invited`), it redirects to
102
+ `/sign-in?error=not_invited`, where `<SignIn />` asks them to request an invitation
103
+ or use another email.
104
+ - `getUser()` returns the same, or `null` when signed out; it throws
105
+ `SegmentStreamApiError` with `code: "not_invited"` for people without an invitation.
106
+ - `can("lead:changeStatus")` is `true` when the user's permissions cover the
107
+ permission. Roles may hold wildcards: `lead:*`, `*:read`, `*:*`. `isAllowed` and
108
+ `grants` apply the same rule to any list of permissions.
109
+ - `signOut()` ends the session, clears its cookies and redirects to `/sign-in` (or
110
+ `{ redirectTo }`). Call it from a Server Function or Route Handler.
111
+
112
+ Use them in Server Components, Route Handlers and Server Functions. Always check the
70
113
  user in Server Functions and Route Handlers too; do not rely on the proxy alone.
71
114
 
72
- To sign out, post a form to the SDK route:
115
+ ## Client components
116
+
117
+ `@segmentstream-cloud/app/ui` holds React client components with plain, neutral CSS
118
+ (no Tailwind). Every class starts with `ss-`; the `--ss-*` custom properties on
119
+ `:root` set colours, radius and font.
120
+
121
+ | Export | Does |
122
+ | -------------------------------------- | ---------------------------------------------------------------------------------- |
123
+ | `<SignIn />` | Email, then the six-digit code; resend after 30 seconds, errors and loading states |
124
+ | `<SignedIn>`, `<SignedOut>` | Render children by session state |
125
+ | `<Can permission="lead:changeStatus">` | Renders children when the user holds the permission; `fallback` otherwise |
126
+ | `<UserMenu />` | Name, email, roles and sign-out |
127
+ | `useUser()` | `{ status, user, error }` from `/api/ontology/me`, loaded once per session |
128
+ | `usePermissions()` | `{ isLoading, roles, permissions, can(permission) }` |
129
+ | `useSession()`, `signOut()` | The Neon Auth session in the browser; sign-out from the browser |
130
+
131
+ `<Can>` only hides controls: the ontology service checks every read and action. A
132
+ Server Component may wrap server-rendered children in `<Can>`:
73
133
 
74
134
  ```tsx
75
- <form action="/api/segmentstream/sign-out" method="post">
76
- <button type="submit">Sign out</button>
77
- </form>
135
+ <Can permission="lead:changeStatus" fallback={lead.status}>
136
+ <ChangeStatusForm lead={lead.id} />
137
+ </Can>
78
138
  ```
79
139
 
140
+ ## The ontology
141
+
142
+ `segmentstream.ontology.<entity>` calls the app's ontology service at `/api/ontology`
143
+ with the signed-in user's Neon Auth JWT, so the service applies their permissions.
144
+ Records, action inputs and action results are typed from the ontology:
145
+
146
+ ```ts
147
+ const { items, nextCursor } = await segmentstream.ontology.lead.find({
148
+ where: { status: { in: ["new", "contacted"] } },
149
+ orderBy: [{ field: "createdAt", direction: "desc" }],
150
+ include: { owner: true },
151
+ limit: 20,
152
+ });
153
+ const lead = await segmentstream.ontology.lead.get(id);
154
+ const history = await segmentstream.ontology.lead.history(id, { limit: 50 });
155
+ const page = await segmentstream.ontology.lead.query({ where: { email: { startsWith: "ada" } } });
156
+
157
+ const input = { lead: id, status: "contacted" } as const;
158
+ const check = await segmentstream.ontology.lead.actions.changeStatus(input, { validateOnly: true });
159
+ const { result, validation, edits } = await segmentstream.ontology.lead.actions.changeStatus(input);
160
+ ```
161
+
162
+ - `find` sends the query as URL parameters, `query` as a JSON body; both return
163
+ `{ items, nextCursor }`. Pass `nextCursor` as `after` for the next page.
164
+ - `actions.<name>(input, { validateOnly })` posts `{ input, options }` and returns
165
+ `{ result, validation, edits }`. Generated actions are there too
166
+ (`actions.create`, `update`, `delete`), and `action(name, input)` runs any action by
167
+ name, such as `upsertByEmail`.
168
+ - Failures throw `SegmentStreamApiError` with the service's `code` (`forbidden`,
169
+ `not_found`, `invalid_input`, `action_rejected`, `conflict`, `not_invited`), `status`,
170
+ `detail`, `requestId` and, for invalid input, `issues`.
171
+ - The types are read from the ontology SDK's `Ontology`, entity and action types
172
+ structurally, so the app needs neither the ontology SDK nor Effect at runtime.
173
+ Without a type argument, any entity name is accepted and records are untyped.
174
+
175
+ ## Query the warehouse
176
+
177
+ ```tsx
178
+ import { sql } from "@segmentstream-cloud/app";
179
+ import { segmentstream } from "@/segmentstream";
180
+
181
+ type Revenue = { order_date: string; total_revenue: number };
182
+
183
+ export default async function Page() {
184
+ await segmentstream.requireUser();
185
+
186
+ const { rows } = await segmentstream.warehouse.query<Revenue>(
187
+ `SELECT order_date, total_revenue
188
+ FROM gold.revenue_summary
189
+ WHERE order_date >= @from
190
+ ORDER BY order_date`,
191
+ { params: { from: sql.date("2026-09-01") } },
192
+ );
193
+
194
+ return <pre>{JSON.stringify(rows, null, 2)}</pre>;
195
+ }
196
+ ```
197
+
198
+ - Warehouse calls authenticate with the workspace app credential,
199
+ `SEGMENTSTREAM_APP_CREDENTIAL`, which SegmentStream sets on deploy and the SDK reads
200
+ on every call. Without it they use the legacy sign-in's session token. The
201
+ credential belongs to the app, so check the user before querying for them.
202
+ - Queries are read-only GoogleSQL over the workspace warehouse. `dataset.table` resolves
203
+ to the workspace's project. Anything other than a single `SELECT` is rejected.
204
+ - Pass values as named `@parameters` in `params`; never build SQL from user input.
205
+ Strings, booleans, `bigint`, safe integers (`INT64`), other numbers (`FLOAT64`), `Date`
206
+ (`TIMESTAMP`) and arrays of these are typed automatically. Use `sql.date()`,
207
+ `sql.datetime()`, `sql.timestamp()`, `sql.numeric()`, `sql.bignumeric()`, `sql.int64()`
208
+ and `sql.array(type, values)` for anything else.
209
+ - `query()` waits for running queries (`timeoutMs`, default 60 s) and follows result
210
+ pages up to `maxRows` (default 10,000); `truncated` reports whether more rows exist.
211
+ `maximumBytesBilled` lowers the per-query byte ceiling; `warehouse.dryRun()` returns the
212
+ estimated bytes and result columns without running the query.
213
+ - Long queries keep running in the warehouse for up to an hour after `query()` stops
214
+ waiting. It then throws `query_timeout` with a `queryId`. Resume the query from a later
215
+ request, for example a Route Handler your page polls, with `warehouse.result(queryId)`.
216
+ Don't hold one request open for minutes.
217
+ - Values arrive as JSON: safe integers as numbers, larger integers and decimals as strings,
218
+ timestamps as ISO strings, records and arrays as JSON strings.
219
+
220
+ `segmentstream.semantic` (governed metrics from semantic models) and
221
+ `segmentstream.fetch(path)` (any SegmentStream API path) act as the signed-in
222
+ SegmentStream member, so they need the legacy sign-in; under Neon Auth they throw
223
+ `app_credential_missing`.
224
+
80
225
  ## How requests are handled
81
226
 
82
- - Page navigations without a session are redirected to SegmentStream to sign in, then
83
- return to the page they started on.
84
- - API routes, `fetch` calls, non-GET requests, and Next.js data requests receive
227
+ - Page navigations without a session are redirected to `/sign-in?returnTo=<path>` and
228
+ return to the page after sign-in.
229
+ - API routes, `fetch` calls, non-GET requests and Next.js data requests receive
85
230
  `401 {"error":"unauthenticated"}` instead of a redirect.
86
- - `/api/segmentstream/*` and your `publicPaths` never require a session.
231
+ - Static assets, `/api/auth/*`, `/sign-in`, `/api/health` and your `publicPaths` never
232
+ require a session.
87
233
 
88
234
  ## Environment
89
235
 
90
- | Variable | Purpose |
91
- | ------------------- | -------------------------------------------------------------------- |
92
- | `SEGMENTSTREAM_URL` | SegmentStream origin. Defaults to `https://cloud.segmentstream.com`. |
236
+ | Variable | Purpose |
237
+ | -------------------------------------- | ------------------------------------------------------------------------------- |
238
+ | `NEON_AUTH_BASE_URL` | The workspace's Neon Auth URL. Set: sign in with Neon Auth. Unset: 0.2 sign-in. |
239
+ | `SEGMENTSTREAM_APP_AUTH_COOKIE_SECRET` | 32 or more characters; signs the session cache cookie. Required with Neon Auth. |
240
+ | `SEGMENTSTREAM_APP_CREDENTIAL` | The workspace app credential for warehouse queries. |
241
+ | `SEGMENTSTREAM_ONTOLOGY_URL` | Optional: the ontology service when it does not run beside the app. |
242
+ | `SEGMENTSTREAM_URL` | SegmentStream origin. Defaults to `https://cloud.segmentstream.com`. |
243
+
244
+ SegmentStream sets the first three in the app's environment on deploy.
93
245
 
94
246
  ## Local development
95
247
 
96
- `next dev` on `http://localhost` works. SegmentStream accepts loopback redirect URIs,
97
- and the SDK drops the `__Host-` prefix and `Secure` attribute only for `http://localhost`,
98
- `127.0.0.1`, and `[::1]`, which browsers otherwise reject over plain HTTP.
248
+ Put the deployed `NEON_AUTH_BASE_URL` and a cookie secret in `app/.env.local`;
249
+ Neon Auth trusts `http://localhost:3000`:
250
+
251
+ ```sh
252
+ NEON_AUTH_BASE_URL=https://ep-….neonauth.….aws.neon.tech/ontology/auth
253
+ SEGMENTSTREAM_APP_AUTH_COOKIE_SECRET=<openssl rand -base64 32>
254
+ SEGMENTSTREAM_ONTOLOGY_URL=http://localhost:3100
255
+ ```
256
+
257
+ With `SEGMENTSTREAM_ONTOLOGY_URL`, server-side ontology calls go there and the proxy
258
+ forwards the browser's `/api/ontology/*` requests to it. Point it at a local
259
+ `npm --prefix ontology run dev` or at the deployed app.
99
260
 
100
261
  ## Security
101
262
 
102
- - The app never sees SegmentStream's identity-provider tokens. It receives a short-lived
103
- (5-minute) ES256 session token scoped to this workspace app and a rotating refresh
104
- token, both in `HttpOnly`, `SameSite=Lax` cookies (`__Host-` and `Secure` in production).
105
- - Session tokens are verified locally against SegmentStream's published keys: issuer,
106
- expiry, workspace, organization, and an `app:` audience.
107
- - The proxy refreshes expired sessions silently. When access is revoked, the next refresh
108
- fails and the user is signed out.
109
- - Sign-in uses the authorization-code flow with PKCE (S256) and a single-use `state`.
263
+ - Sessions are Neon Auth's: `HttpOnly`, `Secure`, `SameSite=Lax` cookies on the app's
264
+ own domain, set through the app's `/api/auth` route. The session cache cookie is
265
+ signed with `SEGMENTSTREAM_APP_AUTH_COOKIE_SECRET`.
266
+ - Signing in proves an email address and grants nothing. The ontology links the
267
+ person to an invited member, and their roles decide what they may read and do. The
268
+ ontology service verifies the user's JWT (EdDSA, 15 minutes) and checks permissions
269
+ on every request; `can()` and `<Can>` only decide what to show.
270
+ - The session JWT and the app credential stay on the server, except that the browser
271
+ components fetch the JWT from the app's own `/api/auth/token` to read
272
+ `/api/ontology/me`.
273
+ - Warehouse queries run with the workspace's own identity; the app never holds
274
+ warehouse credentials.
275
+
276
+ ## Without Neon Auth
277
+
278
+ Without `NEON_AUTH_BASE_URL`, 0.3 behaves exactly as 0.2, so existing apps keep
279
+ working: members of the workspace's organization sign in with their SegmentStream
280
+ account through `/api/segmentstream/[...path]/route.ts`
281
+ (`export const { GET, POST } = segmentstream.handlers();`), the proxy redirects to
282
+ SegmentStream, and `fetch`, `warehouse` and `semantic` act as the member. In that mode
283
+ `roles` and `permissions` are empty, `can()` is `false`, `authHandlers()` answers
284
+ `404 neon_auth_not_configured`, and `segmentstream.ontology` sends the member's app
285
+ token, which the ontology service accepts while workspaces move to Neon Auth. Keep the
286
+ legacy route until every deployment of the app has Neon Auth.
287
+
288
+ ## Upgrading from 0.2
289
+
290
+ Add the auth route, the sign-in page and the stylesheet import; keep the legacy route.
291
+ `AppUser` gained `roles` and `permissions`. Under Neon Auth, people sign in again with
292
+ an email code, and the ontology must have invited them.
110
293
 
111
294
  ## License
112
295
 
package/dist/app.d.ts CHANGED
@@ -1,23 +1,60 @@
1
- import type { SegmentStreamAppConfig } from "./config.js";
1
+ import type { Environment, SegmentStreamAppConfig } from "./config.js";
2
+ import type { SemanticClient, WarehouseClient } from "./data.js";
2
3
  import type { SegmentStreamRouteHandlers } from "./handlers.js";
4
+ import type { AuthRouteHandlers, CreateNeonAuth, SignOutOptions } from "./neon.js";
5
+ import type { OntologyClient } from "./ontology.js";
3
6
  import type { ProxyOptions, SegmentStreamProxy } from "./proxy.js";
4
7
  import type { Fetch } from "./sessions.js";
5
8
  import type { AppUser, KeySet } from "./tokens.js";
6
- export interface SegmentStreamApp {
9
+ /** How the app signs its users in: Neon Auth when `NEON_AUTH_BASE_URL` is set, else SegmentStream. */
10
+ export type AuthenticationMethod = "neon-auth" | "segmentstream";
11
+ /**
12
+ * The app's SegmentStream integration. `O` is the type of the app's ontology,
13
+ * `typeof import("../../ontology").default`, which types {@link SegmentStreamApp.ontology}.
14
+ */
15
+ export interface SegmentStreamApp<O = unknown> {
16
+ /** `neon-auth` when `NEON_AUTH_BASE_URL` is set; `segmentstream` (the 0.2 sign-in) otherwise. */
17
+ readonly authentication: AuthenticationMethod;
7
18
  /** The Next.js Proxy that keeps every non-public request signed in. */
8
19
  readonly proxy: (options?: ProxyOptions) => SegmentStreamProxy;
9
- /** Route Handlers for `app/api/segmentstream/[...path]/route.ts`. */
20
+ /** Route Handlers for `app/src/app/api/auth/[...path]/route.ts`: the Neon Auth API. */
21
+ readonly authHandlers: () => AuthRouteHandlers;
22
+ /** Route Handlers for `app/api/segmentstream/[...path]/route.ts`: the legacy sign-in. */
10
23
  readonly handlers: () => SegmentStreamRouteHandlers;
11
24
  /** The signed-in user for this request, or `null`. */
12
25
  readonly getUser: () => Promise<AppUser | null>;
13
26
  /** The signed-in user; redirects to sign-in when there is none. */
14
27
  readonly requireUser: () => Promise<AppUser>;
15
- /** The verified app session token for this request, or `null`. */
28
+ /** Whether the signed-in user's permissions cover `permission`, wildcards included. */
29
+ readonly can: (permission: string) => Promise<boolean>;
30
+ /**
31
+ * Ends the session and redirects to the sign-in page, or to `redirectTo`. Call it from a Server
32
+ * Function or Route Handler, which may change cookies.
33
+ */
34
+ readonly signOut: (options?: SignOutOptions) => Promise<never>;
35
+ /** The verified legacy app session token for this request, or `null`; always `null` under Neon Auth. */
16
36
  readonly getAccessToken: () => Promise<string | null>;
37
+ /**
38
+ * Calls any SegmentStream API path as the signed-in member, with their organization role
39
+ * and permissions. Server-only; the session token never reaches the browser. Legacy sign-in only.
40
+ */
41
+ readonly fetch: (path: string, init?: RequestInit) => Promise<Response>;
42
+ /**
43
+ * Read-only SQL over the workspace warehouse. Server-only. Uses the workspace app credential
44
+ * (`SEGMENTSTREAM_APP_CREDENTIAL`) when it is set, else the signed-in member's session.
45
+ */
46
+ readonly warehouse: WarehouseClient;
47
+ /** Governed metrics from the workspace's deployed semantic models. Server-only. Legacy sign-in only. */
48
+ readonly semantic: SemanticClient;
49
+ /** The app's ontology service, `/api/ontology`, as the signed-in user. Server-only. */
50
+ readonly ontology: OntologyClient<O>;
17
51
  }
18
52
  /** Replaceable capabilities, for tests. */
19
53
  export interface AppDependencies {
20
54
  readonly fetch: Fetch;
21
55
  readonly keySet: KeySet;
56
+ readonly environment: Environment;
57
+ readonly createNeonAuth: CreateNeonAuth;
58
+ readonly now: () => number;
22
59
  }
23
- export declare function createApp(config: SegmentStreamAppConfig, dependencies?: Partial<AppDependencies>): SegmentStreamApp;
60
+ export declare function createApp<O = unknown>(config: SegmentStreamAppConfig, dependencies?: Partial<AppDependencies>): SegmentStreamApp<O>;
package/dist/app.js CHANGED
@@ -1,11 +1,21 @@
1
- import { resolveConfig } from "./config.js";
1
+ import { createNeonAuth } from "@neondatabase/auth/next/server";
2
+ import { NextResponse } from "next/server";
3
+ import { environmentValue, processEnvironment, resolveConfig, resolveNeonAuth } from "./config.js";
4
+ import { createDataClient } from "./data.js";
5
+ import { SegmentStreamApiError } from "./errors.js";
2
6
  import { createHandlers } from "./handlers.js";
7
+ import { createNeonSession } from "./neon.js";
8
+ import { createOntologyClient } from "./ontology.js";
9
+ import { createOntologyTransport } from "./ontology-transport.js";
3
10
  import { createProxy } from "./proxy.js";
4
11
  import { createServerSession } from "./server.js";
5
12
  import { createSessionClient } from "./sessions.js";
6
13
  import { createTokenVerifier, remoteKeySet } from "./tokens.js";
14
+ const NEON_AUTH_NOT_CONFIGURED = "This app signs in with SegmentStream: NEON_AUTH_BASE_URL is not set. SegmentStream sets it on deploy once the workspace has Neon Auth.";
7
15
  export function createApp(config, dependencies = {}) {
8
- const resolved = resolveConfig(config);
16
+ const environment = dependencies.environment ?? processEnvironment();
17
+ const resolved = resolveConfig(config, environment);
18
+ const neonAuth = resolveNeonAuth(environment);
9
19
  const keySet = dependencies.keySet ?? remoteKeySet(resolved.baseUrl);
10
20
  const fetchImplementation = dependencies.fetch ?? ((input, init) => fetch(input, init));
11
21
  const sessionClient = createSessionClient(resolved, fetchImplementation);
@@ -15,12 +25,76 @@ export function createApp(config, dependencies = {}) {
15
25
  requestToken: sessionClient.requestToken,
16
26
  revokeSession: sessionClient.revoke,
17
27
  };
18
- const serverSession = createServerSession(context);
28
+ const legacy = createServerSession(context);
29
+ const getAppCredential = () => environmentValue(environment, "SEGMENTSTREAM_APP_CREDENTIAL");
30
+ if (neonAuth === null) {
31
+ const data = createDataClient({
32
+ config: resolved,
33
+ fetch: fetchImplementation,
34
+ getAccessToken: legacy.getAccessToken,
35
+ getAppCredential,
36
+ });
37
+ const ontologyUrl = environmentValue(environment, "SEGMENTSTREAM_ONTOLOGY_URL");
38
+ return {
39
+ authentication: "segmentstream",
40
+ proxy: (options) => createProxy(context, options),
41
+ authHandlers: notConfiguredHandlers,
42
+ handlers: () => createHandlers(context),
43
+ getUser: legacy.getUser,
44
+ requireUser: legacy.requireUser,
45
+ // Ontology permissions come with Neon Auth; the legacy sign-in grants none.
46
+ can: async () => false,
47
+ signOut: legacy.signOut,
48
+ getAccessToken: legacy.getAccessToken,
49
+ fetch: data.fetch,
50
+ warehouse: data.warehouse,
51
+ semantic: data.semantic,
52
+ // The ontology service accepts SegmentStream app tokens while workspaces move to Neon Auth.
53
+ ontology: createOntologyClient(createOntologyTransport({
54
+ fetch: fetchImplementation,
55
+ ontologyUrl: ontologyUrl === undefined ? undefined : new URL(ontologyUrl).origin,
56
+ credentials: { token: legacy.getAccessToken },
57
+ })),
58
+ };
59
+ }
60
+ const neon = createNeonSession({
61
+ config: resolved,
62
+ settings: neonAuth,
63
+ createNeonAuth: dependencies.createNeonAuth ?? createNeonAuth,
64
+ fetch: fetchImplementation,
65
+ now: dependencies.now ?? Date.now,
66
+ });
67
+ const data = createDataClient({
68
+ config: resolved,
69
+ fetch: fetchImplementation,
70
+ getAccessToken: noSegmentStreamSession,
71
+ getAppCredential,
72
+ });
19
73
  return {
20
- proxy: (options) => createProxy(context, options),
74
+ authentication: "neon-auth",
75
+ proxy: neon.proxy,
76
+ authHandlers: neon.authHandlers,
21
77
  handlers: () => createHandlers(context),
22
- getUser: serverSession.getUser,
23
- requireUser: serverSession.requireUser,
24
- getAccessToken: serverSession.getAccessToken,
78
+ getUser: neon.getUser,
79
+ requireUser: neon.requireUser,
80
+ can: neon.can,
81
+ signOut: neon.signOut,
82
+ getAccessToken: async () => null,
83
+ fetch: data.fetch,
84
+ warehouse: data.warehouse,
85
+ semantic: data.semantic,
86
+ ontology: createOntologyClient(neon.ontologyTransport),
25
87
  };
26
88
  }
89
+ /** Under Neon Auth the app has no SegmentStream session to call the API with. */
90
+ async function noSegmentStreamSession() {
91
+ throw new SegmentStreamApiError({
92
+ code: "app_credential_missing",
93
+ status: 401,
94
+ detail: "Apps that sign in with Neon Auth have no SegmentStream session. Warehouse queries use the workspace app credential, SEGMENTSTREAM_APP_CREDENTIAL, which is not set; SegmentStream sets it on deploy. Other SegmentStream API calls are not available to these apps yet.",
95
+ });
96
+ }
97
+ function notConfiguredHandlers() {
98
+ const handler = async () => NextResponse.json({ error: "neon_auth_not_configured", detail: NEON_AUTH_NOT_CONFIGURED }, { status: 404, headers: { "cache-control": "no-store" } });
99
+ return { GET: handler, POST: handler, PUT: handler, DELETE: handler, PATCH: handler };
100
+ }
package/dist/config.d.ts CHANGED
@@ -13,6 +13,22 @@ export interface ResolvedConfig {
13
13
  readonly baseUrl: string;
14
14
  readonly issuer: string;
15
15
  }
16
- export declare function resolveConfig(config: SegmentStreamAppConfig): ResolvedConfig;
16
+ /** Environment variables, `process.env` unless a test supplies its own. */
17
+ export type Environment = Readonly<Record<string, string | undefined>>;
18
+ export declare function processEnvironment(): Environment;
19
+ /** Neon Auth for the app's users, configured when `NEON_AUTH_BASE_URL` is set. */
20
+ export interface NeonAuthEnvironment {
21
+ /** `NEON_AUTH_BASE_URL`: the branch's Auth URL. */
22
+ readonly baseUrl: string;
23
+ /** `SEGMENTSTREAM_APP_AUTH_COOKIE_SECRET`: signs the session cache cookie. */
24
+ readonly cookieSecret: string | undefined;
25
+ /** `SEGMENTSTREAM_ONTOLOGY_URL`: the ontology service when it is not beside the app. */
26
+ readonly ontologyUrl: string | undefined;
27
+ }
28
+ export declare function resolveConfig(config: SegmentStreamAppConfig, environment?: Environment): ResolvedConfig;
29
+ /** `null` without `NEON_AUTH_BASE_URL`: the app then signs in with SegmentStream, as in 0.2. */
30
+ export declare function resolveNeonAuth(environment: Environment): NeonAuthEnvironment | null;
17
31
  /** Linear-time trailing slash removal; `/\/+$/` backtracks quadratically on slash runs. */
18
32
  export declare function trimTrailingSlashes(value: string): string;
33
+ /** A variable's trimmed value; unset and blank are the same. */
34
+ export declare function environmentValue(environment: Environment, name: string): string | undefined;
package/dist/config.js CHANGED
@@ -1,11 +1,29 @@
1
1
  export const DEFAULT_BASE_URL = "https://cloud.segmentstream.com";
2
- export function resolveConfig(config) {
2
+ export function processEnvironment() {
3
+ return typeof process === "undefined" ? {} : process.env;
4
+ }
5
+ export function resolveConfig(config, environment = processEnvironment()) {
3
6
  const workspaceId = requireIdentifier("workspaceId", config.workspaceId);
4
7
  const organizationId = requireIdentifier("organizationId", config.organizationId);
5
- const baseUrl = normalizeBaseUrl(config.baseUrl ?? readEnvironment("SEGMENTSTREAM_URL") ?? DEFAULT_BASE_URL);
6
- const issuer = readEnvironment("SEGMENTSTREAM_APP_TOKEN_ISSUER") ?? new URL(baseUrl).origin;
8
+ const baseUrl = normalizeBaseUrl(config.baseUrl ?? environmentValue(environment, "SEGMENTSTREAM_URL") ?? DEFAULT_BASE_URL);
9
+ const issuer = environmentValue(environment, "SEGMENTSTREAM_APP_TOKEN_ISSUER") ?? new URL(baseUrl).origin;
7
10
  return { workspaceId, organizationId, baseUrl, issuer };
8
11
  }
12
+ /** `null` without `NEON_AUTH_BASE_URL`: the app then signs in with SegmentStream, as in 0.2. */
13
+ export function resolveNeonAuth(environment) {
14
+ const baseUrl = environmentValue(environment, "NEON_AUTH_BASE_URL");
15
+ if (baseUrl === undefined) {
16
+ return null;
17
+ }
18
+ const ontologyUrl = environmentValue(environment, "SEGMENTSTREAM_ONTOLOGY_URL");
19
+ return {
20
+ baseUrl: normalizeUrl("NEON_AUTH_BASE_URL", baseUrl),
21
+ cookieSecret: environmentValue(environment, "SEGMENTSTREAM_APP_AUTH_COOKIE_SECRET"),
22
+ ontologyUrl: ontologyUrl === undefined
23
+ ? undefined
24
+ : new URL(normalizeUrl("SEGMENTSTREAM_ONTOLOGY_URL", ontologyUrl)).origin,
25
+ };
26
+ }
9
27
  function requireIdentifier(name, value) {
10
28
  const trimmed = typeof value === "string" ? value.trim() : "";
11
29
  if (trimmed === "") {
@@ -22,20 +40,24 @@ export function trimTrailingSlashes(value) {
22
40
  return value.slice(0, end);
23
41
  }
24
42
  function normalizeBaseUrl(value) {
43
+ return normalizeUrl("SegmentStream base URL", value);
44
+ }
45
+ function normalizeUrl(name, value) {
25
46
  const trimmed = trimTrailingSlashes(value.trim());
26
47
  let url;
27
48
  try {
28
49
  url = new URL(trimmed);
29
50
  }
30
51
  catch (error) {
31
- throw new Error(`SegmentStream base URL is not a valid URL: ${trimmed}`, { cause: error });
52
+ throw new Error(`${name} is not a valid URL: ${trimmed}`, { cause: error });
32
53
  }
33
54
  if (url.protocol !== "https:" && url.protocol !== "http:") {
34
- throw new Error(`SegmentStream base URL must use http or https: ${trimmed}`);
55
+ throw new Error(`${name} must use http or https: ${trimmed}`);
35
56
  }
36
57
  return trimmed;
37
58
  }
38
- function readEnvironment(name) {
39
- const value = typeof process === "undefined" ? undefined : process.env[name];
59
+ /** A variable's trimmed value; unset and blank are the same. */
60
+ export function environmentValue(environment, name) {
61
+ const value = environment[name];
40
62
  return value === undefined || value.trim() === "" ? undefined : value.trim();
41
63
  }
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.