@segmentstream-cloud/app 0.2.0 → 0.3.1

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