@segmentstream-cloud/app 0.16.3 → 0.17.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.
package/dist/app.js CHANGED
@@ -13,6 +13,7 @@ import { createOntologyTransport } from "./ontology-transport.js";
13
13
  import { createProxy } from "./proxy.js";
14
14
  import { createServerSession } from "./server.js";
15
15
  import { createSessionClient } from "./sessions.js";
16
+ import { withFederation } from "./federation.js";
16
17
  import { createTokenVerifier, remoteKeySet } from "./tokens.js";
17
18
  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.";
18
19
  export function createApp(config, dependencies = {}) {
@@ -22,6 +23,22 @@ export function createApp(config, dependencies = {}) {
22
23
  const keySet = dependencies.keySet ?? remoteKeySet(resolved.baseUrl);
23
24
  const fetchImplementation = dependencies.fetch ?? ((input, init) => fetch(input, init));
24
25
  const sessionClient = createSessionClient(resolved, fetchImplementation);
26
+ const issuer = environmentValue(environment, "SEGMENTSTREAM_OIDC_ISSUER");
27
+ const clientId = environmentValue(environment, "SEGMENTSTREAM_OIDC_CLIENT_ID");
28
+ const federated = (app, proofTransport) => {
29
+ if (!issuer && !clientId) {
30
+ return app;
31
+ }
32
+ if (!issuer || !clientId) {
33
+ throw new Error("SEGMENTSTREAM_OIDC_ISSUER and SEGMENTSTREAM_OIDC_CLIENT_ID must be configured together.");
34
+ }
35
+ return withFederation(app, resolved, { issuer, clientId }, {
36
+ fetch: fetchImplementation,
37
+ ontologyUrl: environmentValue(environment, "SEGMENTSTREAM_ONTOLOGY_URL"),
38
+ neon: neonAuth !== null,
39
+ proofTransport,
40
+ });
41
+ };
25
42
  const context = {
26
43
  config: resolved,
27
44
  verifyToken: createTokenVerifier(resolved, keySet),
@@ -44,7 +61,7 @@ export function createApp(config, dependencies = {}) {
44
61
  ontologyUrl: ontologyUrl === undefined ? undefined : new URL(ontologyUrl).origin,
45
62
  credentials: { token: legacy.getAccessToken },
46
63
  });
47
- return {
64
+ return federated({
48
65
  authentication: "segmentstream",
49
66
  proxy: (options) => withUnframeablePages(createProxy(context, options)),
50
67
  authHandlers: notConfiguredHandlers,
@@ -61,7 +78,7 @@ export function createApp(config, dependencies = {}) {
61
78
  ontology: createOntologyClient(ontologyTransport),
62
79
  oauth: createAgentAuthorizations(ontologyTransport),
63
80
  agent: createAgentIdentities(ontologyTransport),
64
- };
81
+ });
65
82
  }
66
83
  const neon = createNeonSession({
67
84
  config: resolved,
@@ -76,7 +93,7 @@ export function createApp(config, dependencies = {}) {
76
93
  getAccessToken: noSegmentStreamSession,
77
94
  getAppCredential,
78
95
  });
79
- return {
96
+ return federated({
80
97
  authentication: "neon-auth",
81
98
  proxy: (options) => withUnframeablePages(neon.proxy(options)),
82
99
  authHandlers: neon.authHandlers,
@@ -92,7 +109,7 @@ export function createApp(config, dependencies = {}) {
92
109
  ontology: createOntologyClient(neon.ontologyTransport),
93
110
  oauth: createAgentAuthorizations(neon.ontologyTransport),
94
111
  agent: createAgentIdentities(neon.ontologyTransport),
95
- };
112
+ }, neon.ontologyTransport);
96
113
  }
97
114
  /** Under Neon Auth the app has no SegmentStream session to call the API with. */
98
115
  async function noSegmentStreamSession() {
@@ -0,0 +1,14 @@
1
+ import type { SegmentStreamApp } from "./app.js";
2
+ import type { ResolvedConfig } from "./config.js";
3
+ import { type OntologyTransport } from "./ontology.js";
4
+ import type { Fetch } from "./sessions.js";
5
+ /** Public-client RP. Only workspace-owned opaque sessions are stored in its session cookie. */
6
+ export declare function withFederation<O>(base: SegmentStreamApp<O>, config: ResolvedConfig, oidc: {
7
+ issuer: string;
8
+ clientId: string;
9
+ }, options: {
10
+ fetch: Fetch;
11
+ ontologyUrl?: string;
12
+ neon: boolean;
13
+ proofTransport?: OntologyTransport;
14
+ }): SegmentStreamApp<O>;
@@ -0,0 +1,313 @@
1
+ import { cookies, headers } from "next/headers";
2
+ import { redirect } from "next/navigation";
3
+ import { NextResponse } from "next/server";
4
+ import { cookiePolicyFor, isLoopbackHostname, safeReturnTo } from "./cookies.js";
5
+ import { isSameOrigin } from "./decision-pages.js";
6
+ import { parseMe } from "./me.js";
7
+ import { createOntologyTransport, ontologyOrigin } from "./ontology-transport.js";
8
+ import { createOntologyClient } from "./ontology.js";
9
+ import { createAgentAuthorizations } from "./oauth.js";
10
+ import { createAgentIdentities } from "./agent.js";
11
+ import { isAllowed } from "./permissions.js";
12
+ import { base64UrlDecode, base64UrlEncode, constantTimeEqual, createCodeChallenge, randomToken, } from "./pkce.js";
13
+ const PREFIX = "/api/segmentstream";
14
+ const CALLBACK = `${PREFIX}/callback`;
15
+ const LINK = `${PREFIX}/link`;
16
+ const encode = (value) => base64UrlEncode(new TextEncoder().encode(JSON.stringify(value)));
17
+ const decode = (value) => JSON.parse(new TextDecoder().decode(base64UrlDecode(value)));
18
+ const cookieName = (workspaceId, secure, purpose = "session") => `${secure ? "__Host-" : ""}ss_oidc_${purpose}.${workspaceId.replace(/[^A-Za-z0-9_-]/g, "_")}`;
19
+ const attributes = (secure, maxAge) => ({
20
+ httpOnly: true,
21
+ secure,
22
+ sameSite: "lax",
23
+ path: "/",
24
+ maxAge,
25
+ });
26
+ /** Public-client RP. Only workspace-owned opaque sessions are stored in its session cookie. */
27
+ export function withFederation(base, config, oidc, options) {
28
+ const issuer = new URL(oidc.issuer);
29
+ if (issuer.protocol !== "https:" ||
30
+ issuer.username ||
31
+ issuer.password ||
32
+ issuer.search ||
33
+ issuer.hash ||
34
+ oidc.issuer.endsWith("/")) {
35
+ throw new Error("SEGMENTSTREAM_OIDC_ISSUER must be an HTTPS issuer without a trailing slash.");
36
+ }
37
+ const token = async () => {
38
+ const cookieStore = await cookies();
39
+ const host = (await headers()).get("host") ?? "";
40
+ const hostname = URL.parse(`http://${host}`)?.hostname ?? "";
41
+ const secure = cookieStore.get(cookieName(config.workspaceId, true))?.value;
42
+ return (secure ??
43
+ (isLoopbackHostname(hostname)
44
+ ? cookieStore.get(cookieName(config.workspaceId, false))?.value
45
+ : undefined) ??
46
+ null);
47
+ };
48
+ const transport = createOntologyTransport({
49
+ fetch: options.fetch,
50
+ ontologyUrl: options.ontologyUrl,
51
+ credentials: { token },
52
+ });
53
+ const ontologyTransport = {
54
+ fetch: async (path, init = {}) => (await token())
55
+ ? transport.fetch(path, init)
56
+ : options.proofTransport
57
+ ? options.proofTransport.fetch(path, init)
58
+ : transport.fetch(path, init),
59
+ };
60
+ async function getUser() {
61
+ if (!(await token())) {
62
+ return options.neon ? base.getUser() : null;
63
+ }
64
+ const response = await transport.fetch("/me", { cache: "no-store" });
65
+ const user = response.ok ? parseMe(await response.json()) : null;
66
+ return user
67
+ ? {
68
+ id: user.id,
69
+ ...(user.email ? { email: user.email } : {}),
70
+ ...(user.name ? { name: user.name } : {}),
71
+ organizationId: config.organizationId,
72
+ workspaceId: config.workspaceId,
73
+ roles: user.roles,
74
+ permissions: user.permissions,
75
+ kind: user.kind,
76
+ active: user.active,
77
+ }
78
+ : null;
79
+ }
80
+ const setSession = (response, request, value) => {
81
+ const { secure } = cookiePolicyFor(new URL(request.url), config.workspaceId);
82
+ response.cookies.set(cookieName(config.workspaceId, secure), value, attributes(secure, 30 * 86400));
83
+ };
84
+ async function GET(request) {
85
+ const { secure } = cookiePolicyFor(new URL(request.url), config.workspaceId);
86
+ const flowPrefix = cookieName(config.workspaceId, secure, "flow");
87
+ const pendingName = cookieName(config.workspaceId, secure, "pending");
88
+ const path = request.nextUrl.pathname;
89
+ if (path === `${PREFIX}/configuration`) {
90
+ return NextResponse.json({ enabled: true }, { headers: { "cache-control": "no-store" } });
91
+ }
92
+ if (path === `${PREFIX}/sign-in`) {
93
+ const state = randomToken();
94
+ const flow = {
95
+ state,
96
+ nonce: randomToken(),
97
+ verifier: randomToken(),
98
+ redirectUri: `${request.nextUrl.origin}${CALLBACK}`,
99
+ returnTo: safeReturnTo(request.nextUrl.searchParams.get("returnTo")),
100
+ expiresAt: Date.now() + 600_000,
101
+ };
102
+ const destination = new URL(`${oidc.issuer}/authorize`);
103
+ destination.search = new URLSearchParams({
104
+ response_type: "code",
105
+ client_id: oidc.clientId,
106
+ redirect_uri: flow.redirectUri,
107
+ scope: "openid profile email",
108
+ state,
109
+ nonce: flow.nonce,
110
+ code_challenge_method: "S256",
111
+ code_challenge: await createCodeChallenge(flow.verifier),
112
+ }).toString();
113
+ const response = NextResponse.redirect(destination);
114
+ const pending = request.cookies
115
+ .getAll()
116
+ .filter((cookie) => cookie.name.startsWith(`${flowPrefix}.`));
117
+ if (pending.length >= 4) {
118
+ for (const cookie of pending) {
119
+ response.cookies.set(cookie.name, "", attributes(secure, 0));
120
+ }
121
+ }
122
+ response.cookies.set(`${flowPrefix}.${state}`, encode(flow), attributes(secure, 600));
123
+ return response;
124
+ }
125
+ if (path === CALLBACK) {
126
+ const params = request.nextUrl.searchParams;
127
+ const state = params.get("state") ?? "";
128
+ const flowName = /^[A-Za-z0-9_-]{43}$/.test(state) ? `${flowPrefix}.${state}` : "";
129
+ const failed = NextResponse.redirect(new URL("/sign-in?error=segmentstream_sign_in_failed", request.url));
130
+ if (flowName) {
131
+ failed.cookies.set(flowName, "", attributes(secure, 0));
132
+ }
133
+ try {
134
+ const value = request.cookies.get(flowName)?.value;
135
+ const flow = value ? decode(value) : undefined;
136
+ if (!flow ||
137
+ [...params.keys()].some((key) => params.getAll(key).length !== 1) ||
138
+ typeof flow.state !== "string" ||
139
+ !constantTimeEqual(flow.state, state) ||
140
+ flow.redirectUri !== `${request.nextUrl.origin}${CALLBACK}` ||
141
+ typeof flow.expiresAt !== "number" ||
142
+ flow.expiresAt <= Date.now() ||
143
+ typeof flow.verifier !== "string" ||
144
+ typeof flow.nonce !== "string" ||
145
+ params.get("iss") !== oidc.issuer ||
146
+ !params.get("code") ||
147
+ params.has("error")) {
148
+ return failed;
149
+ }
150
+ const response = await options.fetch(`${await ontologyOrigin(options.ontologyUrl)}/api/ontology/federation/exchange`, {
151
+ method: "POST",
152
+ headers: { "content-type": "application/json" },
153
+ redirect: "error",
154
+ cache: "no-store",
155
+ signal: AbortSignal.timeout(15000),
156
+ body: JSON.stringify({
157
+ code: params.get("code"),
158
+ verifier: flow.verifier,
159
+ nonce: flow.nonce,
160
+ redirectUri: flow.redirectUri,
161
+ }),
162
+ });
163
+ const result = (await response.json());
164
+ if (!response.ok || typeof result.token !== "string") {
165
+ return failed;
166
+ }
167
+ const returnTo = safeReturnTo(typeof flow.returnTo === "string" ? flow.returnTo : "/");
168
+ const destination = result.status === "proof_required"
169
+ ? `/sign-in?returnTo=${encodeURIComponent(LINK)}`
170
+ : returnTo;
171
+ const completed = NextResponse.redirect(new URL(destination, request.url));
172
+ completed.cookies.set(flowName, "", attributes(secure, 0));
173
+ if (result.status === "proof_required" && result.token.startsWith("ssfl_")) {
174
+ completed.cookies.set(pendingName, encode({ token: result.token, returnTo }), attributes(secure, 600));
175
+ }
176
+ else if (result.status === "signed_in" && result.token.startsWith("ssfs_")) {
177
+ setSession(completed, request, result.token);
178
+ }
179
+ else {
180
+ return failed;
181
+ }
182
+ return completed;
183
+ }
184
+ catch {
185
+ return failed;
186
+ }
187
+ }
188
+ if (path === LINK) {
189
+ if (!options.neon) {
190
+ return new Response("First connect this identity using a workspace email-code login.", {
191
+ status: 403,
192
+ });
193
+ }
194
+ if (!(await base.getUser())) {
195
+ return NextResponse.redirect(new URL(`/sign-in?returnTo=${encodeURIComponent(LINK)}`, request.url));
196
+ }
197
+ return new Response(`<!doctype html><html lang="en"><title>Connect SegmentStream</title>
198
+ <main><h1>Connect your SegmentStream identity</h1><p>Your workspace login has confirmed your access.
199
+ Allow this SegmentStream identity to sign in as the same workspace user?</p>
200
+ <form method="post"><button>Connect and continue</button></form></main></html>`, {
201
+ headers: {
202
+ "content-type": "text/html; charset=utf-8",
203
+ "cache-control": "no-store",
204
+ "content-security-policy": "default-src 'none'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",
205
+ },
206
+ });
207
+ }
208
+ return new Response("Not found", { status: 404 });
209
+ }
210
+ async function POST(request) {
211
+ if (!isSameOrigin(request)) {
212
+ return new Response("Forbidden", { status: 403 });
213
+ }
214
+ const { secure } = cookiePolicyFor(new URL(request.url), config.workspaceId);
215
+ const pendingName = cookieName(config.workspaceId, secure, "pending");
216
+ if (request.nextUrl.pathname !== LINK || !options.neon) {
217
+ return new Response("Not found", { status: 404 });
218
+ }
219
+ try {
220
+ const pending = decode(request.cookies.get(pendingName)?.value ?? "");
221
+ const response = await options.proofTransport.fetch("/federation/link", {
222
+ method: "POST",
223
+ headers: { "content-type": "application/json" },
224
+ body: JSON.stringify({ pending: pending.token }),
225
+ });
226
+ const body = (await response.json());
227
+ if (!response.ok || typeof body.token !== "string" || !body.token.startsWith("ssfs_")) {
228
+ return new Response("The workspace could not link this identity.", { status: 403 });
229
+ }
230
+ const completed = NextResponse.redirect(new URL(safeReturnTo(typeof pending.returnTo === "string" ? pending.returnTo : "/"), request.url), 303);
231
+ setSession(completed, request, body.token);
232
+ completed.cookies.set(pendingName, "", attributes(secure, 0));
233
+ return completed;
234
+ }
235
+ catch {
236
+ return new Response("Sign in to the workspace with an email code, then try again.", {
237
+ status: 403,
238
+ });
239
+ }
240
+ }
241
+ return {
242
+ ...base,
243
+ handlers: () => ({
244
+ GET: async (request) => protectResponse(await GET(request)),
245
+ POST: async (request) => protectResponse(await POST(request)),
246
+ }),
247
+ getUser,
248
+ requireUser: async () => {
249
+ const user = await getUser();
250
+ if (!user) {
251
+ redirect("/sign-in");
252
+ }
253
+ return user;
254
+ },
255
+ can: async (permission) => {
256
+ const user = await getUser();
257
+ return !!user && isAllowed(permission, user);
258
+ },
259
+ getAccessToken: async () => null,
260
+ ontology: createOntologyClient(ontologyTransport),
261
+ oauth: createAgentAuthorizations(ontologyTransport),
262
+ agent: createAgentIdentities(ontologyTransport),
263
+ signOut: async (settings) => {
264
+ if (await token()) {
265
+ await transport.fetch("/federation/logout", { method: "POST" }).catch(() => undefined);
266
+ }
267
+ const store = await cookies();
268
+ for (const secure of [true, false]) {
269
+ store.set(cookieName(config.workspaceId, secure), "", attributes(secure, 0));
270
+ }
271
+ if (options.neon) {
272
+ return base.signOut(settings);
273
+ }
274
+ redirect(safeReturnTo(settings?.redirectTo ?? "/sign-in"));
275
+ },
276
+ proxy: (settings) => async (request) => {
277
+ const { secure } = cookiePolicyFor(new URL(request.url), config.workspaceId);
278
+ const credential = request.cookies.get(cookieName(config.workspaceId, secure))?.value;
279
+ if (request.nextUrl.pathname.startsWith(PREFIX)) {
280
+ return NextResponse.next();
281
+ }
282
+ if (credential &&
283
+ ![
284
+ "/sign-in",
285
+ "/api/auth",
286
+ "/mcp",
287
+ "/api/ontology",
288
+ "/_next",
289
+ ...(settings?.publicPaths ?? []),
290
+ ].some((path) => request.nextUrl.pathname === path || request.nextUrl.pathname.startsWith(`${path}/`))) {
291
+ const origin = options.ontologyUrl ?? request.nextUrl.origin;
292
+ const response = await options.fetch(`${origin}/api/ontology/me`, {
293
+ headers: { authorization: `Bearer ${credential}` },
294
+ cache: "no-store",
295
+ redirect: "error",
296
+ signal: AbortSignal.timeout(10000),
297
+ });
298
+ if (response.ok) {
299
+ return NextResponse.next();
300
+ }
301
+ const denied = NextResponse.redirect(new URL("/sign-in", request.url));
302
+ denied.cookies.set(cookieName(config.workspaceId, secure), "", attributes(secure, 0));
303
+ return denied;
304
+ }
305
+ return base.proxy(settings)(request);
306
+ },
307
+ };
308
+ }
309
+ function protectResponse(response) {
310
+ response.headers.set("cache-control", "no-store");
311
+ response.headers.set("referrer-policy", "no-referrer");
312
+ return response;
313
+ }
@@ -8,6 +8,52 @@ function escape(value) {
8
8
  "'": "&#39;",
9
9
  })[character]);
10
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
+ `;
11
57
  /** Keep a copyable PKCE-bound code visible while trying the client's loopback callback. */
12
58
  export function oauthCompletion(options) {
13
59
  // Web Crypto keeps this module bundleable for client components (no node: imports).
@@ -16,12 +62,14 @@ export function oauthCompletion(options) {
16
62
  const code = redirect === undefined ? null : new URL(redirect).searchParams.get("code");
17
63
  const title = options.approved === false ? "Request denied" : "Authorization complete";
18
64
  const manual = code === null
19
- ? "<p>You can return to your terminal.</p>"
20
- : `
21
- <p>Return to your terminal. If it is still waiting, copy and paste this code:</p>
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">
22
69
  <input id="code" aria-label="Authorization code" readonly value="${escape(code)}">
23
- <button id="copy" type="button">Copy code</button><p id="status" role="status"></p>
24
- <p><a href="${escape(redirect)}">Continue to the client</a></p>`;
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>`;
25
73
  const script = code === null
26
74
  ? ""
27
75
  : `
@@ -36,10 +84,11 @@ export function oauthCompletion(options) {
36
84
  frame.src = ${JSON.stringify(redirect).replace(/</g, "\\u003c")}; document.body.appendChild(frame);`;
37
85
  return new Response(`<!doctype html><html lang="en"><meta charset="utf-8">
38
86
  <meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title>
39
- <style nonce="${nonce}">body{font:16px system-ui;background:#fafafa;color:#18181b;margin:10vh auto;
40
- max-width:480px;padding:24px}input,button{font:inherit;padding:12px;margin:8px 0}input{width:95%}</style>
41
- <img src="${escape(SEGMENTSTREAM_LOGO_URI)}" alt="SegmentStream" width="210" height="45">
42
- <h1>${title}</h1>${manual}<script nonce="${nonce}">${script}</script></html>`, {
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>`, {
43
92
  headers: {
44
93
  "content-type": "text/html; charset=utf-8",
45
94
  "cache-control": "no-store",
@@ -21,3 +21,4 @@ export interface OntologyTransportOptions {
21
21
  * `http://localhost:3100` or the deployed app during local development.
22
22
  */
23
23
  export declare function createOntologyTransport(options: OntologyTransportOptions): OntologyTransport;
24
+ export declare function ontologyOrigin(configured: string | undefined): Promise<string>;
@@ -63,7 +63,7 @@ export function createOntologyTransport(options) {
63
63
  },
64
64
  };
65
65
  }
66
- async function ontologyOrigin(configured) {
66
+ export async function ontologyOrigin(configured) {
67
67
  if (configured !== undefined) {
68
68
  return trimTrailingSlashes(configured);
69
69
  }
@@ -8,7 +8,7 @@ import { useSignIn } from "./use-sign-in.js";
8
8
  */
9
9
  export function SignIn({ title = "Sign in", ...options }) {
10
10
  const ids = useId();
11
- const { step, email, setEmail, code, setCode, pending, error, notice, resendIn, codeLength, destination, signedIn, notInvited, linkInvalid, sessionEmail, submitEmail, submitCode, resend, changeEmail, switchAccount, } = useSignIn(options);
11
+ const { step, segmentstreamSignInUrl, email, setEmail, code, setCode, pending, error, notice, resendIn, codeLength, destination, signedIn, notInvited, linkInvalid, sessionEmail, submitEmail, submitCode, resend, changeEmail, switchAccount, } = useSignIn(options);
12
12
  const codeInput = useRef(null);
13
13
  // The code field takes focus when it appears and again after a wrong code.
14
14
  useEffect(() => {
@@ -24,7 +24,7 @@ export function SignIn({ title = "Sign in", ...options }) {
24
24
  return (_jsxs("section", { className: "ss-card ss-sign-in", "aria-labelledby": `${ids}-title`, children: [_jsx("h1", { id: `${ids}-title`, className: "ss-title", children: "You are signed in" }), _jsx("p", { className: "ss-text", children: sessionEmail === null ? "You already have a session." : `Signed in as ${sessionEmail}.` }), _jsxs("div", { className: "ss-actions", children: [_jsx("a", { className: "ss-button", href: destination, children: "Continue" }), _jsx("button", { type: "button", className: "ss-button ss-button--secondary", onClick: () => void switchAccount(), children: "Use another email" })] })] }));
25
25
  }
26
26
  if (step === "email") {
27
- return (_jsxs("section", { className: "ss-card ss-sign-in", "aria-labelledby": `${ids}-title`, children: [_jsx("h1", { id: `${ids}-title`, className: "ss-title", children: title }), _jsx("p", { className: "ss-text", children: "We will email you a code or a link to sign in." }), _jsxs("form", { className: "ss-form", onSubmit: (event) => {
27
+ return (_jsxs("section", { className: "ss-card ss-sign-in", "aria-labelledby": `${ids}-title`, children: [_jsx("h1", { id: `${ids}-title`, className: "ss-title", children: title }), _jsx("p", { className: "ss-text", children: "We will email you a code or a link to sign in." }), segmentstreamSignInUrl && (_jsx("a", { className: "ss-button ss-button--secondary", href: segmentstreamSignInUrl, children: "Continue with SegmentStream" })), _jsxs("form", { className: "ss-form", onSubmit: (event) => {
28
28
  event.preventDefault();
29
29
  void submitEmail();
30
30
  }, noValidate: true, children: [linkInvalid && (_jsx("p", { className: "ss-notice", role: "status", children: "This sign-in link is not valid anymore. Enter your email to get a new code." })), _jsxs("div", { className: "ss-field", children: [_jsx("label", { className: "ss-label", htmlFor: `${ids}-email`, children: "Email" }), _jsx("input", { id: `${ids}-email`, className: "ss-input", type: "email", name: "email", autoComplete: "email", inputMode: "email", required: true, value: email, onChange: (event) => setEmail(event.target.value), disabled: pending !== null, "aria-invalid": error !== null, "aria-describedby": error === null ? undefined : errorId })] }), error !== null && (_jsx("p", { id: errorId, className: "ss-error", role: "alert", children: error })), _jsx("button", { type: "submit", className: "ss-button", disabled: pending !== null, "aria-busy": pending === "send", children: pending === "send" ? "Sending…" : "Continue" })] })] }));
@@ -19,6 +19,7 @@ export type SignInNoticeCode = "code_resent" | "link_resent";
19
19
  export declare const SIGN_IN_ERROR_MESSAGES: Readonly<Record<SignInErrorCode, string>>;
20
20
  /** Email sign-in for a form the app renders itself. The calls take values, not form events. */
21
21
  export interface SignInController {
22
+ readonly segmentstreamSignInUrl: string | null;
22
23
  /**
23
24
  * `email`, then `code` for a member with a Neon Auth account, or `link` when a sign-in link was
24
25
  * emailed instead: a first sign-in, whose link creates the account. The link step has nothing to
@@ -58,6 +58,21 @@ export function useSignIn({ returnTo, error: reason } = {}) {
58
58
  const [noticeCode, setNoticeCode] = useState(null);
59
59
  const [resendIn, setResendIn] = useState(0);
60
60
  const [switchingAccount, setSwitchingAccount] = useState(false);
61
+ const [segmentstreamEnabled, setSegmentstreamEnabled] = useState(false);
62
+ useEffect(() => {
63
+ let active = true;
64
+ void fetch("/api/segmentstream/configuration", { credentials: "same-origin" })
65
+ .then(async (response) => response.ok ? (await response.json()) : null)
66
+ .then((body) => {
67
+ if (active) {
68
+ setSegmentstreamEnabled(body?.enabled === true);
69
+ }
70
+ })
71
+ .catch(() => undefined);
72
+ return () => {
73
+ active = false;
74
+ };
75
+ }, []);
61
76
  const verifying = useRef(false);
62
77
  const resendTimer = useRef(undefined);
63
78
  const destination = safeReturnTo(returnTo ?? pageReturnTo);
@@ -168,6 +183,9 @@ export function useSignIn({ returnTo, error: reason } = {}) {
168
183
  setPending(null);
169
184
  }
170
185
  return {
186
+ segmentstreamSignInUrl: segmentstreamEnabled
187
+ ? `/api/segmentstream/sign-in?returnTo=${encodeURIComponent(destination)}`
188
+ : null,
171
189
  step,
172
190
  email,
173
191
  setEmail,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segmentstream-cloud/app",
3
- "version": "0.16.3",
3
+ "version": "0.17.0",
4
4
  "description": "Sign-in, users, permissions, the ontology client and warehouse data access for SegmentStream workspace apps built with Next.js.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "SegmentStream",