@segmentstream-cloud/app 0.8.0 → 0.9.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.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Environment, SegmentStreamAppConfig } from "./config.js";
2
2
  import type { WarehouseClient } from "./data.js";
3
3
  import type { SegmentStreamRouteHandlers } from "./handlers.js";
4
+ import type { AgentAuthorizations } from "./oauth.js";
4
5
  import type { AuthRouteHandlers, CreateNeonAuth, SignOutOptions } from "./neon.js";
5
6
  import type { OntologyClient } from "./ontology.js";
6
7
  import type { ProxyOptions, SegmentStreamProxy } from "./proxy.js";
@@ -46,6 +47,11 @@ export interface SegmentStreamApp<O = unknown> {
46
47
  readonly warehouse: WarehouseClient;
47
48
  /** The app's ontology service, `/api/ontology`, as the signed-in user. Server-only. */
48
49
  readonly ontology: OntologyClient<O>;
50
+ /**
51
+ * Agents connecting to the app's MCP server: the request the consent page shows the signed-in
52
+ * member, and the Route Handler that carries out their decision. Server-only.
53
+ */
54
+ readonly oauth: AgentAuthorizations;
49
55
  }
50
56
  /** Replaceable capabilities, for tests. */
51
57
  export interface AppDependencies {
package/dist/app.js CHANGED
@@ -5,6 +5,7 @@ import { createDataClient } from "./data.js";
5
5
  import { SegmentStreamApiError } from "./errors.js";
6
6
  import { createHandlers } from "./handlers.js";
7
7
  import { createNeonSession } from "./neon.js";
8
+ import { createAgentAuthorizations, withUnframeableConsent } from "./oauth.js";
8
9
  import { createOntologyClient } from "./ontology.js";
9
10
  import { createOntologyTransport } from "./ontology-transport.js";
10
11
  import { createProxy } from "./proxy.js";
@@ -35,9 +36,15 @@ export function createApp(config, dependencies = {}) {
35
36
  getAppCredential,
36
37
  });
37
38
  const ontologyUrl = environmentValue(environment, "SEGMENTSTREAM_ONTOLOGY_URL");
39
+ // The ontology service accepts SegmentStream app tokens while workspaces move to Neon Auth.
40
+ const ontologyTransport = createOntologyTransport({
41
+ fetch: fetchImplementation,
42
+ ontologyUrl: ontologyUrl === undefined ? undefined : new URL(ontologyUrl).origin,
43
+ credentials: { token: legacy.getAccessToken },
44
+ });
38
45
  return {
39
46
  authentication: "segmentstream",
40
- proxy: (options) => createProxy(context, options),
47
+ proxy: (options) => withUnframeableConsent(createProxy(context, options)),
41
48
  authHandlers: notConfiguredHandlers,
42
49
  handlers: () => createHandlers(context),
43
50
  getUser: legacy.getUser,
@@ -48,12 +55,8 @@ export function createApp(config, dependencies = {}) {
48
55
  getAccessToken: legacy.getAccessToken,
49
56
  fetch: data.fetch,
50
57
  warehouse: data.warehouse,
51
- // The ontology service accepts SegmentStream app tokens while workspaces move to Neon Auth.
52
- ontology: createOntologyClient(createOntologyTransport({
53
- fetch: fetchImplementation,
54
- ontologyUrl: ontologyUrl === undefined ? undefined : new URL(ontologyUrl).origin,
55
- credentials: { token: legacy.getAccessToken },
56
- }), data.aggregateTransport),
58
+ ontology: createOntologyClient(ontologyTransport, data.aggregateTransport),
59
+ oauth: createAgentAuthorizations(ontologyTransport),
57
60
  };
58
61
  }
59
62
  const neon = createNeonSession({
@@ -71,7 +74,7 @@ export function createApp(config, dependencies = {}) {
71
74
  });
72
75
  return {
73
76
  authentication: "neon-auth",
74
- proxy: neon.proxy,
77
+ proxy: (options) => withUnframeableConsent(neon.proxy(options)),
75
78
  authHandlers: neon.authHandlers,
76
79
  handlers: () => createHandlers(context),
77
80
  getUser: neon.getUser,
@@ -82,6 +85,7 @@ export function createApp(config, dependencies = {}) {
82
85
  fetch: data.fetch,
83
86
  warehouse: data.warehouse,
84
87
  ontology: createOntologyClient(neon.ontologyTransport, data.aggregateTransport),
88
+ oauth: createAgentAuthorizations(neon.ontologyTransport),
85
89
  };
86
90
  }
87
91
  /** Under Neon Auth the app has no SegmentStream session to call the API with. */
package/dist/index.d.ts CHANGED
@@ -19,6 +19,8 @@ export { NOT_INVITED, SIGN_IN_LINK_INVALID, SegmentStreamApiError } from "./erro
19
19
  export { grants, isAllowed } from "./permissions.js";
20
20
  export { sql } from "./parameters.js";
21
21
  export type { AuthenticationMethod, SegmentStreamApp } from "./app.js";
22
+ export { CONSENT_PAGE } from "./oauth.js";
23
+ export type { AgentAuthorizationRequest, AgentAuthorizations, AgentScope } from "./oauth.js";
22
24
  export type { ApiIssue } from "./errors.js";
23
25
  export type { CurrentUser } from "./me.js";
24
26
  export type { AuthRouteHandler, AuthRouteHandlers, SignOutOptions } from "./neon.js";
package/dist/index.js CHANGED
@@ -19,4 +19,5 @@ export function createSegmentStreamApp(config) {
19
19
  export { NOT_INVITED, SIGN_IN_LINK_INVALID, SegmentStreamApiError } from "./errors.js";
20
20
  export { grants, isAllowed } from "./permissions.js";
21
21
  export { sql } from "./parameters.js";
22
+ export { CONSENT_PAGE } from "./oauth.js";
22
23
  export { createBlobStorageClient, } from "./storage.js";
package/dist/neon.js CHANGED
@@ -14,6 +14,13 @@ import { isApiRequest, unauthenticatedResponse } from "./sign-in.js";
14
14
  export const SIGN_IN_PAGE = "/sign-in";
15
15
  /** Where `app/src/app/api/auth/[...path]/route.ts` serves the Neon Auth API. */
16
16
  export const AUTH_API_PATH = "/api/auth";
17
+ /** The paths the ontology service answers: its HTTP API, its MCP server and its OAuth metadata. */
18
+ const ONTOLOGY_PATHS = [
19
+ "/api/ontology",
20
+ "/mcp",
21
+ "/.well-known/oauth-protected-resource",
22
+ "/.well-known/oauth-authorization-server",
23
+ ];
17
24
  /**
18
25
  * Paths every visitor may open: the Neon Auth API, the sign-in page, the health check, and the
19
26
  * Next.js assets and metadata files the sign-in page needs.
@@ -303,8 +310,9 @@ export function createNeonSession(dependencies) {
303
310
  const publicPaths = [...OPEN_PATHS, ...(options.publicPaths ?? [])].map(normalizePublicPath);
304
311
  return async function neonProxy(request) {
305
312
  const { pathname, search } = request.nextUrl;
306
- if (settings.ontologyUrl !== undefined && matchesPathPrefix(pathname, "/api/ontology")) {
307
- // Local development: the service runs elsewhere; deployed, /api/ontology never reaches the app.
313
+ if (settings.ontologyUrl !== undefined &&
314
+ ONTOLOGY_PATHS.some((path) => matchesPathPrefix(pathname, path))) {
315
+ // Local development: the service runs elsewhere; deployed, its paths never reach the app.
308
316
  return NextResponse.rewrite(new URL(`${pathname}${search}`, settings.ontologyUrl));
309
317
  }
310
318
  if (publicPaths.some((path) => matchesPathPrefix(pathname, path))) {
@@ -0,0 +1,44 @@
1
+ import type { OntologyTransport } from "./ontology.js";
2
+ import { type SegmentStreamProxy } from "./proxy.js";
3
+ /** The app's page where a signed-in member decides whether an agent may act for them. */
4
+ export declare const CONSENT_PAGE = "/oauth/consent";
5
+ /**
6
+ * The proxy, with the consent page unframeable: no other site may show it in a frame and trick a
7
+ * member into clicking Allow. Every app gets this from the SDK, whatever its Next.js config says.
8
+ */
9
+ export declare function withUnframeableConsent(proxy: SegmentStreamProxy): SegmentStreamProxy;
10
+ /** Everything the member may do, or only the reads of it. */
11
+ export type AgentScope = "ontology" | "ontology:read";
12
+ /** An agent asking to act for the signed-in member, as the consent page shows it. */
13
+ export interface AgentAuthorizationRequest {
14
+ readonly id: string;
15
+ readonly client: {
16
+ /** The name the agent registered with. Agents choose it themselves, so show the host too. */
17
+ readonly name: string;
18
+ readonly uri: string | null;
19
+ /** Where the member's browser goes after the decision: the agent's own address. */
20
+ readonly redirectUri: string;
21
+ readonly redirectHost: string;
22
+ };
23
+ readonly scope: AgentScope;
24
+ /** What the member may do, and so the most the agent may do for them. */
25
+ readonly permissions: ReadonlyArray<string>;
26
+ readonly expiresAt: string;
27
+ }
28
+ export interface AgentAuthorizations {
29
+ /**
30
+ * The request the consent page at `/oauth/consent?request=<id>` shows, for the signed-in member;
31
+ * `null` when it expired or was already decided. Server-only.
32
+ */
33
+ readonly request: (id: string) => Promise<AgentAuthorizationRequest | null>;
34
+ /**
35
+ * The Route Handler the consent page's form posts to, at `app/src/app/api/oauth/consent/route.ts`.
36
+ * It takes `request`, `decision` (`approve` or `deny`) and, to allow reading only, `scope`
37
+ * (`ontology:read`), and sends the browser back to the agent.
38
+ */
39
+ readonly consentHandler: () => {
40
+ readonly POST: (request: Request) => Promise<Response>;
41
+ };
42
+ }
43
+ /** The consent flow, over the ontology service as the signed-in member. */
44
+ export declare function createAgentAuthorizations(transport: OntologyTransport): AgentAuthorizations;
package/dist/oauth.js ADDED
@@ -0,0 +1,112 @@
1
+ import { NextResponse } from "next/server";
2
+ import { apiErrorFrom, readJson } from "./errors.js";
3
+ import { matchesPathPrefix } from "./proxy.js";
4
+ /** The app's page where a signed-in member decides whether an agent may act for them. */
5
+ export const CONSENT_PAGE = "/oauth/consent";
6
+ /**
7
+ * The proxy, with the consent page unframeable: no other site may show it in a frame and trick a
8
+ * member into clicking Allow. Every app gets this from the SDK, whatever its Next.js config says.
9
+ */
10
+ export function withUnframeableConsent(proxy) {
11
+ return async (request) => {
12
+ const response = await proxy(request);
13
+ if (matchesPathPrefix(request.nextUrl.pathname, CONSENT_PAGE)) {
14
+ response.headers.set("x-frame-options", "DENY");
15
+ response.headers.set("content-security-policy", "frame-ancestors 'none'");
16
+ }
17
+ return response;
18
+ };
19
+ }
20
+ function parseRequest(payload) {
21
+ if (typeof payload !== "object" || payload === null) {
22
+ return null;
23
+ }
24
+ const body = payload;
25
+ const client = (body.client ?? {});
26
+ if (typeof body.id !== "string" ||
27
+ typeof client.name !== "string" ||
28
+ typeof client.redirect_uri !== "string" ||
29
+ typeof client.redirect_host !== "string" ||
30
+ (body.scope !== "ontology" && body.scope !== "ontology:read") ||
31
+ !Array.isArray(body.permissions) ||
32
+ typeof body.expires_at !== "string") {
33
+ return null;
34
+ }
35
+ return {
36
+ id: body.id,
37
+ client: {
38
+ name: client.name,
39
+ uri: typeof client.uri === "string" ? client.uri : null,
40
+ redirectUri: client.redirect_uri,
41
+ redirectHost: client.redirect_host,
42
+ },
43
+ scope: body.scope,
44
+ permissions: body.permissions.filter((permission) => typeof permission === "string"),
45
+ expiresAt: body.expires_at,
46
+ };
47
+ }
48
+ /**
49
+ * Whether a form post came from the app's own pages. A decision must never be made from another
50
+ * site, which could otherwise approve an agent in the member's name.
51
+ */
52
+ function sameOrigin(request) {
53
+ const origin = request.headers.get("origin");
54
+ if (origin !== null) {
55
+ return origin === new URL(request.url).origin;
56
+ }
57
+ return request.headers.get("sec-fetch-site") === "same-origin";
58
+ }
59
+ function requestPath(id, decision) {
60
+ const path = `/oauth/requests/${encodeURIComponent(id)}`;
61
+ return decision === undefined ? path : `${path}/${decision}`;
62
+ }
63
+ /** The consent flow, over the ontology service as the signed-in member. */
64
+ export function createAgentAuthorizations(transport) {
65
+ return {
66
+ async request(id) {
67
+ if (id === "") {
68
+ return null;
69
+ }
70
+ const response = await transport.fetch(requestPath(id), {
71
+ method: "GET",
72
+ headers: { accept: "application/json" },
73
+ });
74
+ const payload = await readJson(response);
75
+ if (response.status === 404) {
76
+ return null;
77
+ }
78
+ if (!response.ok) {
79
+ throw apiErrorFrom(response.status, payload, "The ontology service refused the request.");
80
+ }
81
+ return parseRequest(payload);
82
+ },
83
+ consentHandler: () => ({
84
+ async POST(request) {
85
+ if (!sameOrigin(request)) {
86
+ return NextResponse.json({ error: "forbidden", detail: "Decide on an agent from the app's own page." }, { status: 403 });
87
+ }
88
+ const form = await request.formData();
89
+ const id = String(form.get("request") ?? "");
90
+ const decision = form.get("decision") === "approve" ? "approve" : "deny";
91
+ const response = await transport.fetch(requestPath(id, decision), {
92
+ method: "POST",
93
+ headers: { "content-type": "application/json", accept: "application/json" },
94
+ body: JSON.stringify(form.get("scope") === "ontology:read" ? { scope: "ontology:read" } : {}),
95
+ });
96
+ const payload = await readJson(response);
97
+ if (response.status === 404) {
98
+ return NextResponse.redirect(new URL(`${CONSENT_PAGE}?expired=1`, request.url), 303);
99
+ }
100
+ if (!response.ok) {
101
+ throw apiErrorFrom(response.status, payload, "The ontology service refused the decision.");
102
+ }
103
+ const redirect = payload?.redirect;
104
+ if (typeof redirect !== "string" || !URL.canParse(redirect)) {
105
+ throw apiErrorFrom(502, payload, "The ontology service answered without a redirect.");
106
+ }
107
+ // 303: the agent's redirect URI receives a GET, whatever method brought the browser here.
108
+ return NextResponse.redirect(redirect, 303);
109
+ },
110
+ }),
111
+ };
112
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segmentstream-cloud/app",
3
- "version": "0.8.0",
3
+ "version": "0.9.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",