@segmentstream/app 0.0.0-stage → 0.38.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 (125) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +2 -0
  3. package/README.md +1236 -2
  4. package/dist/agent.d.ts +56 -0
  5. package/dist/agent.js +203 -0
  6. package/dist/app.d.ts +82 -0
  7. package/dist/app.js +434 -0
  8. package/dist/browser-auth-capability.d.ts +2 -0
  9. package/dist/browser-auth-capability.js +2 -0
  10. package/dist/browser-revision.d.ts +19 -0
  11. package/dist/browser-revision.js +140 -0
  12. package/dist/channel-protocol.d.ts +16 -0
  13. package/dist/channel-protocol.js +17 -0
  14. package/dist/channel-session.d.ts +22 -0
  15. package/dist/channel-session.js +89 -0
  16. package/dist/config.d.ts +53 -0
  17. package/dist/config.js +112 -0
  18. package/dist/context.d.ts +10 -0
  19. package/dist/context.js +1 -0
  20. package/dist/cookies.d.ts +56 -0
  21. package/dist/cookies.js +131 -0
  22. package/dist/data.d.ts +233 -0
  23. package/dist/data.js +206 -0
  24. package/dist/decision-pages.d.ts +14 -0
  25. package/dist/decision-pages.js +87 -0
  26. package/dist/deployment.d.ts +30 -0
  27. package/dist/deployment.js +72 -0
  28. package/dist/errors.d.ts +60 -0
  29. package/dist/errors.js +123 -0
  30. package/dist/federation.d.ts +24 -0
  31. package/dist/federation.js +613 -0
  32. package/dist/groups.d.ts +95 -0
  33. package/dist/groups.js +280 -0
  34. package/dist/handlers.d.ts +10 -0
  35. package/dist/handlers.js +161 -0
  36. package/dist/index.d.ts +46 -0
  37. package/dist/index.js +29 -0
  38. package/dist/jobs.d.ts +49 -0
  39. package/dist/jobs.js +40 -0
  40. package/dist/legacy.d.ts +77 -0
  41. package/dist/legacy.js +57 -0
  42. package/dist/logo.d.ts +1 -0
  43. package/dist/logo.js +25 -0
  44. package/dist/me.d.ts +42 -0
  45. package/dist/me.js +54 -0
  46. package/dist/navigation/build.d.ts +68 -0
  47. package/dist/navigation/build.js +290 -0
  48. package/dist/navigation/cli.d.ts +19 -0
  49. package/dist/navigation/cli.js +37 -0
  50. package/dist/navigation/generate.d.ts +43 -0
  51. package/dist/navigation/generate.js +138 -0
  52. package/dist/navigation/next.d.ts +10 -0
  53. package/dist/navigation/next.js +18 -0
  54. package/dist/navigation-protocol.d.ts +55 -0
  55. package/dist/navigation-protocol.js +298 -0
  56. package/dist/neon.d.ts +101 -0
  57. package/dist/neon.js +539 -0
  58. package/dist/oauth-completion.d.ts +6 -0
  59. package/dist/oauth-completion.js +106 -0
  60. package/dist/oauth.d.ts +59 -0
  61. package/dist/oauth.js +129 -0
  62. package/dist/ontology-names.d.ts +10 -0
  63. package/dist/ontology-names.js +38 -0
  64. package/dist/ontology-transport.d.ts +34 -0
  65. package/dist/ontology-transport.js +130 -0
  66. package/dist/ontology-wire.d.ts +66 -0
  67. package/dist/ontology-wire.js +355 -0
  68. package/dist/ontology.d.ts +381 -0
  69. package/dist/ontology.js +367 -0
  70. package/dist/pages.d.ts +6 -0
  71. package/dist/pages.js +6 -0
  72. package/dist/parameters.d.ts +55 -0
  73. package/dist/parameters.js +118 -0
  74. package/dist/permissions.d.ts +19 -0
  75. package/dist/permissions.js +55 -0
  76. package/dist/pkce.d.ts +8 -0
  77. package/dist/pkce.js +40 -0
  78. package/dist/proxy.d.ts +25 -0
  79. package/dist/proxy.js +135 -0
  80. package/dist/request-timing.d.ts +6 -0
  81. package/dist/request-timing.js +53 -0
  82. package/dist/return-to.d.ts +2 -0
  83. package/dist/return-to.js +24 -0
  84. package/dist/server.d.ts +11 -0
  85. package/dist/server.js +80 -0
  86. package/dist/sessions.d.ts +27 -0
  87. package/dist/sessions.js +72 -0
  88. package/dist/sign-in.d.ts +37 -0
  89. package/dist/sign-in.js +95 -0
  90. package/dist/storage.d.ts +79 -0
  91. package/dist/storage.js +180 -0
  92. package/dist/telegram-launch.d.ts +7 -0
  93. package/dist/telegram-launch.js +134 -0
  94. package/dist/telegram.d.ts +2 -0
  95. package/dist/telegram.js +6 -0
  96. package/dist/tokens.d.ts +51 -0
  97. package/dist/tokens.js +70 -0
  98. package/dist/ui/agent-connect.d.ts +18 -0
  99. package/dist/ui/agent-connect.js +23 -0
  100. package/dist/ui/components.d.ts +27 -0
  101. package/dist/ui/components.js +71 -0
  102. package/dist/ui/hooks.d.ts +19 -0
  103. package/dist/ui/hooks.js +56 -0
  104. package/dist/ui/index.d.ts +22 -0
  105. package/dist/ui/index.js +15 -0
  106. package/dist/ui/navigation.d.ts +5 -0
  107. package/dist/ui/navigation.js +8 -0
  108. package/dist/ui/segmentstream-logo.d.ts +2 -0
  109. package/dist/ui/segmentstream-logo.js +7 -0
  110. package/dist/ui/sign-in-check.d.ts +14 -0
  111. package/dist/ui/sign-in-check.js +41 -0
  112. package/dist/ui/sign-in.d.ts +17 -0
  113. package/dist/ui/sign-in.js +65 -0
  114. package/dist/ui/store.d.ts +80 -0
  115. package/dist/ui/store.js +280 -0
  116. package/dist/ui/styles.css +249 -0
  117. package/dist/ui/telegram-sign-in.d.ts +16 -0
  118. package/dist/ui/telegram-sign-in.js +21 -0
  119. package/dist/ui/use-browser-sign-in.d.ts +52 -0
  120. package/dist/ui/use-browser-sign-in.js +169 -0
  121. package/dist/ui/use-channel-sign-in.d.ts +10 -0
  122. package/dist/ui/use-channel-sign-in.js +118 -0
  123. package/dist/ui/use-sign-in.d.ts +109 -0
  124. package/dist/ui/use-sign-in.js +304 -0
  125. package/package.json +95 -4
package/dist/neon.js ADDED
@@ -0,0 +1,539 @@
1
+ import { NEON_AUTH_SESSION_COOKIE_NAME, NEON_AUTH_SESSION_DATA_COOKIE_NAME, parseSetCookies, } from "@neondatabase/auth/server";
2
+ import { decodeJwt } from "jose";
3
+ import { cookies } from "next/headers";
4
+ import { redirect } from "next/navigation";
5
+ import { NextResponse } from "next/server";
6
+ import { hasChannelApps } from "./config.js";
7
+ import { replaceRequestCookies, safeReturnTo } from "./cookies.js";
8
+ import { createChannelSession } from "./channel-session.js";
9
+ import { NOT_INVITED, SIGN_IN_LINK_INVALID, SegmentStreamApiError, apiErrorFrom, readJson, } from "./errors.js";
10
+ import { parseMe } from "./me.js";
11
+ import { createOntologyTransport } from "./ontology-transport.js";
12
+ import { isAllowed } from "./permissions.js";
13
+ import { matchesPathPrefix, normalizePublicPath, wellKnownResponse } from "./proxy.js";
14
+ import { isApiRequest, unauthenticatedResponse } from "./sign-in.js";
15
+ /** The app's sign-in page, which renders `<SignIn />` from `@segmentstream/app/ui`. */
16
+ export const SIGN_IN_PAGE = "/sign-in";
17
+ /** Where `app/src/app/api/auth/[...path]/route.ts` serves the Neon Auth API. */
18
+ export const AUTH_API_PATH = "/api/auth";
19
+ /** The ontology's HTTP API and MCP transport; metadata probes are handled separately. */
20
+ const ONTOLOGY_PATHS = ["/api/ontology", "/mcp"];
21
+ /**
22
+ * Paths every visitor may open: the Neon Auth API, the sign-in page, the health check, and the
23
+ * Next.js assets and metadata files the sign-in page needs.
24
+ */
25
+ export const OPEN_PATHS = [
26
+ AUTH_API_PATH,
27
+ SIGN_IN_PAGE,
28
+ "/api/segmentstream/configuration",
29
+ "/api/health",
30
+ "/_next",
31
+ "/favicon.ico",
32
+ "/robots.txt",
33
+ "/sitemap.xml",
34
+ "/manifest.webmanifest",
35
+ ];
36
+ /**
37
+ * Better Auth answers a magic link that names a callback with a redirect to that path on Neon
38
+ * Auth's own origin; Neon's proxy follows it and loses the session cookie. Without them it answers
39
+ * JSON with the cookies, and the app's auth route redirects itself.
40
+ */
41
+ const MAGIC_LINK_CALLBACK_PARAMETERS = ["callbackURL", "errorCallbackURL", "newUserCallbackURL"];
42
+ /** How long a user's roles and permissions are reused before `/api/ontology/me` is asked again. */
43
+ const USER_TTL_MS = 60_000;
44
+ /** Neon Auth JWTs live 15 minutes; a cached one is replaced a minute before it expires. */
45
+ const TOKEN_EARLY_REFRESH_MS = 60_000;
46
+ const CACHE_LIMIT = 1000;
47
+ /** Entries that expire, at most {@link CACHE_LIMIT} of them; the oldest go first. */
48
+ class ExpiringCache {
49
+ now;
50
+ #entries = new Map();
51
+ constructor(now) {
52
+ this.now = now;
53
+ }
54
+ get(key) {
55
+ const entry = this.#entries.get(key);
56
+ if (entry === undefined) {
57
+ return undefined;
58
+ }
59
+ if (entry.expiresAt <= this.now()) {
60
+ this.#entries.delete(key);
61
+ return undefined;
62
+ }
63
+ return entry.value;
64
+ }
65
+ set(key, value, expiresAt) {
66
+ this.#entries.delete(key);
67
+ this.#entries.set(key, { value, expiresAt });
68
+ for (const oldest of this.#entries.keys()) {
69
+ if (this.#entries.size <= CACHE_LIMIT) {
70
+ break;
71
+ }
72
+ this.#entries.delete(oldest);
73
+ }
74
+ }
75
+ delete(key) {
76
+ this.#entries.delete(key);
77
+ }
78
+ }
79
+ /**
80
+ * Neon Auth (Managed Better Auth) for the app's users: sessions in cookies through
81
+ * `@neondatabase/auth`, and roles and permissions from the ontology's `GET /api/ontology/me`.
82
+ */
83
+ export function createNeonSession(dependencies) {
84
+ const { config, settings, now } = dependencies;
85
+ const channel = createChannelSession({
86
+ enabled: hasChannelApps(config.channels),
87
+ workspaceId: config.workspaceId,
88
+ ontologyUrl: settings.ontologyUrl,
89
+ fetch: dependencies.fetch,
90
+ });
91
+ const tokens = new ExpiringCache(now);
92
+ const users = new ExpiringCache(now);
93
+ const loadingUsers = new Map();
94
+ let server;
95
+ let routeHandlers;
96
+ let middleware;
97
+ /** Created on first use, so that builds without the cookie secret still succeed. */
98
+ function neonAuth() {
99
+ if (server !== undefined) {
100
+ return server;
101
+ }
102
+ const secret = settings.cookieSecret;
103
+ if (secret === undefined || secret.length < 32) {
104
+ throw new Error("SEGMENTSTREAM_APP_AUTH_COOKIE_SECRET must hold at least 32 characters when NEON_AUTH_BASE_URL is set. " +
105
+ "SegmentStream sets both on deploy; for local development copy them into app/.env.local.");
106
+ }
107
+ server = dependencies.createNeonAuth({
108
+ baseUrl: settings.baseUrl,
109
+ // Lax keeps the session on links followed from email and other sites.
110
+ cookies: { secret, sameSite: "lax" },
111
+ });
112
+ return server;
113
+ }
114
+ async function identity() {
115
+ // Reading cookies first marks the page dynamic, so prerendering at build time stops here
116
+ // before any configuration is needed. Thrown errors pass through untouched: Next.js signals
117
+ // dynamic rendering with them.
118
+ await cookies();
119
+ const channelSession = await channelIdentity();
120
+ if (channelSession) {
121
+ return channelSession;
122
+ }
123
+ const result = await neonAuth().getSession();
124
+ if (result.error !== null) {
125
+ if (result.error.status === 401) {
126
+ return channelIdentity();
127
+ }
128
+ const fallback = await channelIdentity();
129
+ if (fallback) {
130
+ return fallback;
131
+ }
132
+ throw authUnavailable(result.error);
133
+ }
134
+ const session = result.data?.session;
135
+ const user = result.data?.user;
136
+ if (session === null || session === undefined || user === null || user === undefined) {
137
+ return channelIdentity();
138
+ }
139
+ return {
140
+ sessionId: session.id,
141
+ userId: user.id,
142
+ email: user.email ?? undefined,
143
+ name: user.name ?? undefined,
144
+ };
145
+ }
146
+ async function channelIdentity() {
147
+ const session = await channel.read();
148
+ return session
149
+ ? {
150
+ sessionId: session.user.id,
151
+ userId: session.user.id,
152
+ name: session.user.name ?? undefined,
153
+ email: session.user.email ?? undefined,
154
+ channel: session,
155
+ }
156
+ : null;
157
+ }
158
+ /** The session's JWT for the ontology service, reused until shortly before it expires. */
159
+ async function sessionToken(session) {
160
+ if (session.channel) {
161
+ return session.channel.token;
162
+ }
163
+ const cached = tokens.get(session.sessionId);
164
+ if (cached !== undefined) {
165
+ return cached;
166
+ }
167
+ const result = await neonAuth().token();
168
+ if (result.error !== null) {
169
+ if (result.error.status === 401) {
170
+ return channel.token();
171
+ }
172
+ throw authUnavailable(result.error);
173
+ }
174
+ const token = result.data?.token;
175
+ if (typeof token !== "string" || token === "") {
176
+ throw authUnavailable(new Error("Neon Auth returned no token."));
177
+ }
178
+ tokens.set(session.sessionId, token, tokenRefreshAt(token, now()));
179
+ return token;
180
+ }
181
+ function transportFor(session) {
182
+ return createOntologyTransport({
183
+ fetch: dependencies.fetch,
184
+ ontologyUrl: settings.ontologyUrl,
185
+ credentials: {
186
+ token: () => sessionToken(session),
187
+ rejected: () => tokens.delete(session.sessionId),
188
+ },
189
+ });
190
+ }
191
+ /** Ontology requests as the signed-in user. */
192
+ const ontologyTransport = {
193
+ async fetch(path, init) {
194
+ const session = await identity();
195
+ if (session === null) {
196
+ throw new SegmentStreamApiError({
197
+ code: "app_session_missing",
198
+ status: 401,
199
+ detail: "No signed-in user for this request; call the ontology from Server Components, Route Handlers, or Server Functions behind the SegmentStream proxy.",
200
+ });
201
+ }
202
+ return transportFor(session).fetch(path, init);
203
+ },
204
+ };
205
+ async function askMe(session) {
206
+ const response = await transportFor(session).fetch("/me", {
207
+ method: "GET",
208
+ headers: { accept: "application/json" },
209
+ });
210
+ const payload = await readJson(response);
211
+ if (!response.ok) {
212
+ throw apiErrorFrom(response.status, payload, "The ontology service rejected the user.");
213
+ }
214
+ const user = parseMe(payload);
215
+ if (user === null) {
216
+ throw new SegmentStreamApiError({
217
+ code: "ontology_invalid_response",
218
+ status: 502,
219
+ detail: "GET /api/ontology/me did not answer { user, roles, permissions }.",
220
+ retryable: true,
221
+ });
222
+ }
223
+ return user;
224
+ }
225
+ async function currentUser(session, options = {}) {
226
+ if (session.channel) {
227
+ // Channel sessions are read from the service on every request.
228
+ return session.channel.user;
229
+ }
230
+ if (options.fresh !== true) {
231
+ const cached = users.get(session.sessionId);
232
+ if (cached !== undefined) {
233
+ return cached;
234
+ }
235
+ const pending = loadingUsers.get(session.sessionId);
236
+ if (pending !== undefined) {
237
+ return pending;
238
+ }
239
+ }
240
+ // A fresh request replaces one in flight, which may have started before the change it is for.
241
+ const loading = askMe(session)
242
+ .then((user) => {
243
+ // Only the latest request caches its answer, so an older one cannot replace a fresh one.
244
+ if (loadingUsers.get(session.sessionId) === loading) {
245
+ users.set(session.sessionId, user, now() + USER_TTL_MS);
246
+ }
247
+ return user;
248
+ })
249
+ .finally(() => {
250
+ if (loadingUsers.get(session.sessionId) === loading) {
251
+ loadingUsers.delete(session.sessionId);
252
+ }
253
+ });
254
+ loadingUsers.set(session.sessionId, loading);
255
+ return loading;
256
+ }
257
+ function appUser(session, user) {
258
+ const email = user.email ?? session.email;
259
+ const name = user.name ?? session.name;
260
+ return {
261
+ id: user.id,
262
+ ...(email === undefined ? {} : { email }),
263
+ ...(name === undefined ? {} : { name }),
264
+ organizationId: config.organizationId,
265
+ workspaceId: config.workspaceId,
266
+ roles: user.roles,
267
+ permissions: user.permissions,
268
+ ...(user.scopes === undefined ? {} : { scopes: user.scopes }),
269
+ kind: user.kind,
270
+ active: user.active,
271
+ authUserId: session.userId,
272
+ };
273
+ }
274
+ async function getUser(options = {}) {
275
+ const session = await identity();
276
+ if (session === null) {
277
+ return null;
278
+ }
279
+ return appUser(session, await currentUser(session, options));
280
+ }
281
+ async function requireUser(options = {}) {
282
+ const session = await identity();
283
+ if (session === null) {
284
+ redirect(SIGN_IN_PAGE);
285
+ }
286
+ let user;
287
+ let notInvited = false;
288
+ try {
289
+ user = await currentUser(session, options);
290
+ }
291
+ catch (error) {
292
+ if (!(error instanceof SegmentStreamApiError) || error.code !== NOT_INVITED) {
293
+ throw error;
294
+ }
295
+ notInvited = true;
296
+ }
297
+ if (notInvited || user === undefined) {
298
+ // The sign-in page explains it and offers to sign out; redirect() must not run inside try.
299
+ redirect(`${SIGN_IN_PAGE}?error=${NOT_INVITED}`);
300
+ }
301
+ return appUser(session, user);
302
+ }
303
+ async function can(permission, options = {}) {
304
+ let user;
305
+ try {
306
+ user = await getUser(options);
307
+ }
308
+ catch (error) {
309
+ if (error instanceof SegmentStreamApiError && error.code === NOT_INVITED) {
310
+ return false;
311
+ }
312
+ throw error;
313
+ }
314
+ return user !== null && isAllowed(permission, user);
315
+ }
316
+ async function signOut(options = {}) {
317
+ await channel.signOut();
318
+ const session = await identity().catch(() => null);
319
+ if (session !== null) {
320
+ tokens.delete(session.sessionId);
321
+ users.delete(session.sessionId);
322
+ }
323
+ try {
324
+ await neonAuth().signOut();
325
+ }
326
+ catch {
327
+ // Ending the session upstream is best effort; the cookies below end it in this browser.
328
+ }
329
+ const cookieStore = await cookies();
330
+ for (const name of [NEON_AUTH_SESSION_COOKIE_NAME, NEON_AUTH_SESSION_DATA_COOKIE_NAME]) {
331
+ cookieStore.set(name, "", {
332
+ httpOnly: true,
333
+ secure: true,
334
+ sameSite: "lax",
335
+ path: "/",
336
+ maxAge: 0,
337
+ expires: new Date(0),
338
+ });
339
+ }
340
+ redirect(safeReturnTo(options.redirectTo ?? SIGN_IN_PAGE));
341
+ }
342
+ function authHandlers() {
343
+ const handlers = () => (routeHandlers ??= neonAuth().handler());
344
+ return {
345
+ GET: async (request, context) => {
346
+ const { path } = await context.params;
347
+ return path.length === 2 && path[0] === "magic-link" && path[1] === "verify"
348
+ ? completeMagicLink(request, context, () => handlers().GET)
349
+ : handlers().GET(request, context);
350
+ },
351
+ POST: (request, context) => handlers().POST(request, context),
352
+ PUT: (request, context) => handlers().PUT(request, context),
353
+ DELETE: (request, context) => handlers().DELETE(request, context),
354
+ PATCH: (request, context) => handlers().PATCH(request, context),
355
+ };
356
+ }
357
+ function proxy(options = {}) {
358
+ const publicPaths = [...OPEN_PATHS, ...(options.publicPaths ?? [])].map(normalizePublicPath);
359
+ return async function neonProxy(request) {
360
+ const { pathname, search } = request.nextUrl;
361
+ const discovery = wellKnownResponse(request, settings.ontologyUrl);
362
+ if (discovery !== undefined) {
363
+ return discovery;
364
+ }
365
+ if (settings.ontologyUrl !== undefined &&
366
+ ONTOLOGY_PATHS.some((path) => matchesPathPrefix(pathname, path))) {
367
+ // Local development: the service runs elsewhere; deployed, its paths never reach the app.
368
+ return NextResponse.rewrite(new URL(`${pathname}${search}`, settings.ontologyUrl));
369
+ }
370
+ if (publicPaths.some((path) => matchesPathPrefix(pathname, path))) {
371
+ return NextResponse.next();
372
+ }
373
+ const channelResponse = await channel.proxy(request);
374
+ if (channelResponse) {
375
+ return channelResponse;
376
+ }
377
+ middleware ??= neonAuth().middleware({ loginUrl: SIGN_IN_PAGE });
378
+ const response = await middleware(request);
379
+ if (!isSignInRedirect(request, response)) {
380
+ return forwardSessionCookies(request, response);
381
+ }
382
+ const replacement = isApiRequest(request)
383
+ ? unauthenticatedResponse()
384
+ : signInPageRedirect(request);
385
+ // Neon Auth clears a stale session cache cookie on the way to sign-in.
386
+ for (const cookie of response.headers.getSetCookie()) {
387
+ replacement.headers.append("set-cookie", cookie);
388
+ }
389
+ return replacement;
390
+ };
391
+ }
392
+ return {
393
+ proxy,
394
+ authHandlers,
395
+ getUser,
396
+ requireUser,
397
+ can,
398
+ signOut,
399
+ ontologyTransport,
400
+ identityToken: async () => {
401
+ const session = await identity();
402
+ return session && !session.channel ? sessionToken(session) : null;
403
+ },
404
+ };
405
+ }
406
+ /** Neon Auth's middleware sends visitors without a session to the configured sign-in page. */
407
+ function isSignInRedirect(request, response) {
408
+ const location = response.headers.get("location");
409
+ if (response.status < 300 || response.status >= 400 || location === null) {
410
+ return false;
411
+ }
412
+ try {
413
+ const target = new URL(location, request.nextUrl.origin);
414
+ return target.origin === request.nextUrl.origin && target.pathname === SIGN_IN_PAGE;
415
+ }
416
+ catch {
417
+ return false;
418
+ }
419
+ }
420
+ const MIDDLEWARE_REQUEST_HEADER = "x-middleware-request-";
421
+ /**
422
+ * Neon Auth's middleware refreshes the session cookies on its response only, so the page this
423
+ * request renders would still read the old ones and ask Neon Auth again, which cannot set cookies
424
+ * from a Server Component. The refreshed cookies go into the request too: through
425
+ * `NextResponse.cookies`, which Next.js merges into `cookies()`, and the `Cookie` header, which
426
+ * Route Handlers read.
427
+ */
428
+ function forwardSessionCookies(request, response) {
429
+ const setCookies = response.headers.getSetCookie();
430
+ if (response.headers.get("x-middleware-next") !== "1" || setCookies.length === 0) {
431
+ return response;
432
+ }
433
+ const refreshed = setCookies.flatMap((header) => parseSetCookies(header));
434
+ const expired = (cookie) => cookie.value === "" ||
435
+ (cookie.maxAge !== undefined && cookie.maxAge <= 0) ||
436
+ (cookie.expires !== undefined && cookie.expires.getTime() <= Date.now());
437
+ const requestHeaders = new Headers(request.headers);
438
+ // Keep the request headers Neon Auth's middleware added.
439
+ for (const [name, value] of response.headers) {
440
+ if (name.startsWith(MIDDLEWARE_REQUEST_HEADER)) {
441
+ requestHeaders.set(name.slice(MIDDLEWARE_REQUEST_HEADER.length), value);
442
+ }
443
+ }
444
+ const removed = new Set(refreshed.filter(expired).map((cookie) => cookie.name));
445
+ const live = refreshed.filter((cookie) => !expired(cookie));
446
+ const cookieHeader = replaceRequestCookies(request.headers.get("cookie"), Object.fromEntries(live.map((cookie) => [cookie.name, cookie.value])))
447
+ .split("; ")
448
+ .filter((pair) => pair !== "" && !removed.has(pair.split("=", 1)[0] ?? ""))
449
+ .join("; ");
450
+ requestHeaders.set("cookie", cookieHeader);
451
+ const forwarded = NextResponse.next({ request: { headers: requestHeaders } });
452
+ for (const { name, value, ...options } of refreshed) {
453
+ forwarded.cookies.set(name, value, options);
454
+ }
455
+ return forwarded;
456
+ }
457
+ /**
458
+ * Signs in with an emailed magic link on the app's own domain: Neon Auth verifies the token
459
+ * without a callback and sets the session cookies, which go to the browser with a redirect to the
460
+ * link's `callbackURL` path. A link Neon Auth rejects leads to the sign-in page instead.
461
+ */
462
+ async function completeMagicLink(request, context, verify) {
463
+ const url = new URL(request.url);
464
+ const destination = safeReturnTo(url.searchParams.get("callbackURL"));
465
+ const token = url.searchParams.get("token");
466
+ if (token === null || token === "") {
467
+ return invalidSignInLink(url.origin, destination);
468
+ }
469
+ for (const name of MAGIC_LINK_CALLBACK_PARAMETERS) {
470
+ url.searchParams.delete(name);
471
+ }
472
+ const handler = verify();
473
+ let response;
474
+ try {
475
+ response = await handler(new Request(url, request), context);
476
+ }
477
+ catch {
478
+ return invalidSignInLink(url.origin, destination);
479
+ }
480
+ // The body holds the session token; only the cookies go on to the browser.
481
+ void response.body?.cancel().catch(() => undefined);
482
+ if (!response.ok) {
483
+ return invalidSignInLink(url.origin, destination);
484
+ }
485
+ const redirect = NextResponse.redirect(new URL(destination, url.origin), 303);
486
+ for (const cookie of response.headers.getSetCookie()) {
487
+ redirect.headers.append("set-cookie", cookie);
488
+ }
489
+ redirect.headers.set("cache-control", "no-store");
490
+ return redirect;
491
+ }
492
+ /** An expired, used or unknown sign-in link: the sign-in page explains it and sends a code. */
493
+ function invalidSignInLink(origin, destination) {
494
+ const url = new URL(SIGN_IN_PAGE, origin);
495
+ url.searchParams.set("error", SIGN_IN_LINK_INVALID);
496
+ if (destination !== "/") {
497
+ url.searchParams.set("returnTo", destination);
498
+ }
499
+ const response = NextResponse.redirect(url, 303);
500
+ response.headers.set("cache-control", "no-store");
501
+ return response;
502
+ }
503
+ /** Page navigations go to the sign-in page and come back to where they started. */
504
+ function signInPageRedirect(request) {
505
+ const url = new URL(SIGN_IN_PAGE, request.nextUrl.origin);
506
+ const returnTo = safeReturnTo(`${request.nextUrl.pathname}${request.nextUrl.search}`);
507
+ if (returnTo !== "/" && returnTo.length <= 1024) {
508
+ url.searchParams.set("returnTo", returnTo);
509
+ }
510
+ const response = NextResponse.redirect(url, 307);
511
+ response.headers.set("cache-control", "no-store");
512
+ return response;
513
+ }
514
+ function tokenRefreshAt(token, now) {
515
+ try {
516
+ const { exp } = decodeJwt(token);
517
+ if (typeof exp === "number") {
518
+ return Math.max(now, exp * 1000 - TOKEN_EARLY_REFRESH_MS);
519
+ }
520
+ }
521
+ catch {
522
+ // Not a JWT the SDK can read; the service decides whether it is valid.
523
+ }
524
+ return now;
525
+ }
526
+ function authUnavailable(cause) {
527
+ const code = typeof cause === "object" && cause !== null && "code" in cause && typeof cause.code === "string"
528
+ ? cause.code
529
+ : undefined;
530
+ return new SegmentStreamApiError({
531
+ code: "auth_unavailable",
532
+ status: 503,
533
+ detail: code === undefined
534
+ ? "Neon Auth could not be reached. Try again in a moment."
535
+ : `Neon Auth could not be reached (${code}). Try again in a moment.`,
536
+ retryable: true,
537
+ cause,
538
+ });
539
+ }
@@ -0,0 +1,6 @@
1
+ /** Keep a copyable PKCE-bound code visible while trying the client's loopback callback. */
2
+ export declare function oauthCompletion(options: {
3
+ readonly redirect?: string;
4
+ readonly approved?: boolean;
5
+ }): Response;
6
+ export declare function loopbackRedirect(value: string): boolean;
@@ -0,0 +1,106 @@
1
+ import { SEGMENTSTREAM_LOGO_URI } from "./logo.js";
2
+ function escape(value) {
3
+ return value.replace(/[&<>"']/g, (character) => ({
4
+ "&": "&amp;",
5
+ "<": "&lt;",
6
+ ">": "&gt;",
7
+ '"': "&quot;",
8
+ "'": "&#39;",
9
+ })[character]);
10
+ }
11
+ // The shadcn/ui tokens and Card, Input and Button styles of the workspace app template, inlined so
12
+ // the page needs no external stylesheet under its strict CSP.
13
+ const STYLE = `
14
+ :root{color-scheme:light;--radius:0.625rem;--background:oklch(1 0 0);--foreground:oklch(0.145 0 0);
15
+ --card:oklch(1 0 0);--primary:oklch(0.205 0 0);--primary-foreground:oklch(0.985 0 0);
16
+ --muted:oklch(0.97 0 0);--muted-foreground:oklch(0.556 0 0);--accent:oklch(0.97 0 0);
17
+ --border:oklch(0.922 0 0);--input:oklch(0.922 0 0);--ring:oklch(0.708 0 0);--field:transparent;
18
+ --outline:var(--background);--outline-hover:var(--accent);--logo:none}
19
+ @media (prefers-color-scheme:dark){:root{color-scheme:dark;--background:oklch(0.145 0 0);
20
+ --foreground:oklch(0.985 0 0);--card:oklch(0.205 0 0);--primary:oklch(0.922 0 0);
21
+ --primary-foreground:oklch(0.205 0 0);--muted:oklch(0.269 0 0);--muted-foreground:oklch(0.708 0 0);
22
+ --accent:oklch(0.269 0 0);--border:oklch(1 0 0 / 10%);--input:oklch(1 0 0 / 15%);
23
+ --ring:oklch(0.556 0 0);--field:oklch(1 0 0 / 4.5%);--outline:oklch(1 0 0 / 4.5%);
24
+ --outline-hover:oklch(1 0 0 / 7.5%);--logo:invert(1)}}
25
+ *{box-sizing:border-box}
26
+ body{margin:0;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji";
27
+ color:var(--foreground);background:color-mix(in oklab,var(--muted) 40%,var(--background));
28
+ -webkit-font-smoothing:antialiased}
29
+ main{min-height:100svh;max-width:calc(28rem + 3rem);margin:0 auto;padding:1.5rem;display:flex;
30
+ flex-direction:column;align-items:center;justify-content:center;gap:1.5rem}
31
+ main>img{filter:var(--logo);height:auto;max-width:100%}
32
+ .card{width:100%;display:flex;flex-direction:column;gap:1.5rem;padding:1.5rem;
33
+ background:var(--card);border:1px solid var(--border);border-radius:calc(var(--radius) + 4px);
34
+ box-shadow:0 1px 3px 0 rgb(0 0 0 / 0.1),0 1px 2px -1px rgb(0 0 0 / 0.1)}
35
+ header,.content{display:grid;gap:0.5rem}
36
+ h1{margin:0;font-size:1.25rem;line-height:1.75rem;font-weight:600;letter-spacing:-0.025em}
37
+ p{margin:0}.muted{color:var(--muted-foreground);font-size:0.875rem;line-height:1.25rem}
38
+ .status:empty{display:none}.secured{font-size:0.75rem;line-height:1rem;text-align:center}
39
+ .field{display:flex;gap:0.5rem}
40
+ input{flex:1;min-width:0;height:2.25rem;padding:0 0.75rem;border:1px solid var(--input);
41
+ border-radius:calc(var(--radius) - 2px);background:var(--field);color:inherit;
42
+ font:0.875rem/1 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;letter-spacing:0.025em;
43
+ box-shadow:0 1px 2px 0 rgb(0 0 0 / 0.05);outline:none}
44
+ .button{display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;height:2.25rem;
45
+ padding:0 1rem;border:1px solid transparent;border-radius:calc(var(--radius) - 2px);
46
+ background:var(--primary);color:var(--primary-foreground);font-family:inherit;font-size:0.875rem;
47
+ font-weight:500;line-height:1.25rem;text-decoration:none;white-space:nowrap;cursor:pointer;outline:none;
48
+ transition:background-color .15s,box-shadow .15s}
49
+ .button:hover{background:color-mix(in oklab,var(--primary) 90%,transparent)}
50
+ .outline{background:var(--outline);color:var(--foreground);border-color:var(--input);
51
+ box-shadow:0 1px 2px 0 rgb(0 0 0 / 0.05)}.outline:hover{background:var(--outline-hover)}
52
+ input:focus-visible,.button:focus-visible{border-color:var(--ring);
53
+ box-shadow:0 0 0 3px color-mix(in oklab,var(--ring) 50%,transparent)}
54
+ footer{display:flex;justify-content:flex-end}
55
+ @media (max-width:30rem){.field{flex-direction:column}input{flex:none}.button{width:100%}}
56
+ `;
57
+ /** Keep a copyable PKCE-bound code visible while trying the client's loopback callback. */
58
+ export function oauthCompletion(options) {
59
+ // Web Crypto keeps this module bundleable for client components (no node: imports).
60
+ const nonce = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(18))));
61
+ const redirect = options.redirect;
62
+ const code = redirect === undefined ? null : new URL(redirect).searchParams.get("code");
63
+ const title = options.approved === false ? "Request denied" : "Authorization complete";
64
+ const manual = code === null
65
+ ? `<header><h1>${title}</h1><p class="muted">You can return to your terminal.</p></header>`
66
+ : `<header><h1>${title}</h1>
67
+ <p class="muted">Return to your terminal. If it is still waiting, copy and paste this code:</p></header>
68
+ <div class="content"><div class="field">
69
+ <input id="code" aria-label="Authorization code" readonly value="${escape(code)}">
70
+ <button id="copy" class="button" type="button">Copy code</button></div>
71
+ <p id="status" class="muted status" role="status"></p></div>
72
+ <footer><a class="button outline" href="${escape(redirect)}">Continue to the client</a></footer>`;
73
+ const script = code === null
74
+ ? ""
75
+ : `
76
+ document.getElementById('copy').addEventListener('click', async () => {
77
+ const input = document.getElementById('code');
78
+ try { await navigator.clipboard.writeText(input.value);
79
+ document.getElementById('status').textContent = 'Copied';
80
+ } catch { input.select(); document.getElementById('status').textContent = 'Select and copy the code'; }
81
+ });
82
+ // A frame attempts loopback without losing the fallback page on connection failure.
83
+ const frame = document.createElement('iframe'); frame.hidden = true;
84
+ frame.src = ${JSON.stringify(redirect).replace(/</g, "\\u003c")}; document.body.appendChild(frame);`;
85
+ return new Response(`<!doctype html><html lang="en"><meta charset="utf-8">
86
+ <meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title>
87
+ <style nonce="${nonce}">${STYLE}</style>
88
+ <main><img src="${escape(SEGMENTSTREAM_LOGO_URI)}" alt="SegmentStream" width="210" height="45">
89
+ <section class="card">${manual}</section>
90
+ <p class="muted secured">Secured by SegmentStream</p></main>
91
+ <script nonce="${nonce}">${script}</script></html>`, {
92
+ headers: {
93
+ "content-type": "text/html; charset=utf-8",
94
+ "cache-control": "no-store",
95
+ "referrer-policy": "no-referrer",
96
+ "x-frame-options": "DENY",
97
+ "content-security-policy": `default-src 'none'; img-src data:; style-src 'nonce-${nonce}'; ` +
98
+ `script-src 'nonce-${nonce}'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'; ` +
99
+ `frame-src ${redirect === undefined ? "'none'" : new URL(redirect).origin}`,
100
+ },
101
+ });
102
+ }
103
+ export function loopbackRedirect(value) {
104
+ const url = new URL(value);
105
+ return url.protocol === "http:" && ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
106
+ }