@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
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,49 @@ 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";
47
52
 
48
- export const { GET, POST } = segmentstream.handlers();
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;
66
+
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";
49
79
  ```
50
80
 
51
81
  ## Read the signed-in user
@@ -56,28 +86,92 @@ import { segmentstream } from "@/segmentstream";
56
86
  export default async function Page() {
57
87
  const user = await segmentstream.requireUser();
58
88
 
59
- 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
+ );
60
94
  }
61
95
  ```
62
96
 
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
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
71
113
  user in Server Functions and Route Handlers too; do not rely on the proxy alone.
72
114
 
73
- 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>`:
74
133
 
75
134
  ```tsx
76
- <form action="/api/segmentstream/sign-out" method="post">
77
- <button type="submit">Sign out</button>
78
- </form>
135
+ <Can permission="lead:changeStatus" fallback={lead.status}>
136
+ <ChangeStatusForm lead={lead.id} />
137
+ </Can>
138
+ ```
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);
79
160
  ```
80
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
+
81
175
  ## Query the warehouse
82
176
 
83
177
  ```tsx
@@ -87,6 +181,8 @@ import { segmentstream } from "@/segmentstream";
87
181
  type Revenue = { order_date: string; total_revenue: number };
88
182
 
89
183
  export default async function Page() {
184
+ await segmentstream.requireUser();
185
+
90
186
  const { rows } = await segmentstream.warehouse.query<Revenue>(
91
187
  `SELECT order_date, total_revenue
92
188
  FROM gold.revenue_summary
@@ -99,6 +195,10 @@ export default async function Page() {
99
195
  }
100
196
  ```
101
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.
102
202
  - Queries are read-only GoogleSQL over the workspace warehouse. `dataset.table` resolves
103
203
  to the workspace's project. Anything other than a single `SELECT` is rejected.
104
204
  - Pass values as named `@parameters` in `params`; never build SQL from user input.
@@ -112,79 +212,84 @@ export default async function Page() {
112
212
  estimated bytes and result columns without running the query.
113
213
  - Long queries keep running in the warehouse for up to an hour after `query()` stops
114
214
  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.
215
+ request, for example a Route Handler your page polls, with `warehouse.result(queryId)`.
216
+ Don't hold one request open for minutes.
118
217
  - Values arrive as JSON: safe integers as numbers, larger integers and decimals as strings,
119
218
  timestamps as ISO strings, records and arrays as JSON strings.
120
219
 
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`.
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`.
151
224
 
152
225
  ## How requests are handled
153
226
 
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
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
157
230
  `401 {"error":"unauthenticated"}` instead of a redirect.
158
- - `/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.
159
233
 
160
234
  ## Environment
161
235
 
162
- | Variable | Purpose |
163
- | ------------------- | -------------------------------------------------------------------- |
164
- | `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.
165
245
 
166
246
  ## Local development
167
247
 
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.
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.
171
260
 
172
261
  ## Security
173
262
 
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.
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.
188
293
 
189
294
  ## License
190
295
 
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
  }