@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.
- package/README.md +198 -87
- package/dist/app.d.ts +35 -8
- package/dist/app.js +73 -8
- package/dist/config.d.ts +17 -1
- package/dist/config.js +29 -7
- package/dist/cookies.d.ts +16 -7
- package/dist/cookies.js +36 -47
- package/dist/data.d.ts +6 -18
- package/dist/data.js +17 -36
- package/dist/errors.d.ts +38 -0
- package/dist/errors.js +68 -0
- package/dist/handlers.js +43 -19
- package/dist/index.d.ts +18 -8
- package/dist/index.js +11 -6
- package/dist/me.d.ts +24 -0
- package/dist/me.js +33 -0
- package/dist/neon.d.ts +92 -0
- package/dist/neon.js +471 -0
- package/dist/ontology-transport.d.ts +23 -0
- package/dist/ontology-transport.js +95 -0
- package/dist/ontology.d.ts +217 -0
- package/dist/ontology.js +141 -0
- package/dist/permissions.d.ts +17 -0
- package/dist/permissions.js +33 -0
- package/dist/proxy.d.ts +2 -0
- package/dist/proxy.js +29 -13
- package/dist/return-to.d.ts +2 -0
- package/dist/return-to.js +24 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +35 -5
- package/dist/sign-in.d.ts +5 -1
- package/dist/sign-in.js +17 -4
- package/dist/tokens.d.ts +22 -3
- package/dist/ui/components.d.ts +27 -0
- package/dist/ui/components.js +63 -0
- package/dist/ui/hooks.d.ts +19 -0
- package/dist/ui/hooks.js +56 -0
- package/dist/ui/index.d.ts +10 -0
- package/dist/ui/index.js +8 -0
- package/dist/ui/navigation.d.ts +5 -0
- package/dist/ui/navigation.js +8 -0
- package/dist/ui/sign-in.d.ts +20 -0
- package/dist/ui/sign-in.js +200 -0
- package/dist/ui/store.d.ts +61 -0
- package/dist/ui/store.js +155 -0
- package/dist/ui/styles.css +249 -0
- package/package.json +24 -7
package/README.md
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# @segmentstream-cloud/app
|
|
2
2
|
|
|
3
|
-
Sign-in,
|
|
4
|
-
Next.js (App Router, version 16 or later)
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
34
|
-
publicPaths: ["/
|
|
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
|
|
41
|
+
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
|
39
42
|
};
|
|
40
43
|
```
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
|
|
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 "
|
|
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
|
-
|
|
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
|
|
95
|
+
return (
|
|
96
|
+
<p>
|
|
97
|
+
{user.name ?? user.email}: {user.roles.join(", ")}
|
|
98
|
+
</p>
|
|
99
|
+
);
|
|
60
100
|
}
|
|
61
101
|
```
|
|
62
102
|
|
|
63
|
-
- `
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
77
|
-
<
|
|
78
|
-
</
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
155
|
-
return to the page
|
|
156
|
-
- API routes, `fetch` calls, non-GET requests
|
|
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/
|
|
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
|
|
163
|
-
|
|
|
164
|
-
| `
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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 {
|
|
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
|
|
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
|
|
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:
|
|
70
|
+
getAccessToken: noSegmentStreamSession,
|
|
71
|
+
getAppCredential,
|
|
24
72
|
});
|
|
25
73
|
return {
|
|
26
|
-
|
|
74
|
+
authentication: "neon-auth",
|
|
75
|
+
proxy: neon.proxy,
|
|
76
|
+
authHandlers: neon.authHandlers,
|
|
27
77
|
handlers: () => createHandlers(context),
|
|
28
|
-
getUser:
|
|
29
|
-
requireUser:
|
|
30
|
-
|
|
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
|
-
|
|
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
|
|
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 ??
|
|
6
|
-
const issuer =
|
|
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(
|
|
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(
|
|
55
|
+
throw new Error(`${name} must use http or https: ${trimmed}`);
|
|
35
56
|
}
|
|
36
57
|
return trimmed;
|
|
37
58
|
}
|
|
38
|
-
|
|
39
|
-
|
|
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
|
}
|