@segmentstream-cloud/app 0.10.0 → 0.11.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/README.md CHANGED
@@ -140,6 +140,7 @@ user in Server Functions and Route Handlers too; do not rely on the proxy alone.
140
140
  | `<SignedIn>`, `<SignedOut>` | Render children by session state |
141
141
  | `<Can permission="lead:changeStatus">` | Renders children when the user holds the permission; `fallback` otherwise |
142
142
  | `<UserMenu />` | Name, email, roles and sign-out |
143
+ | `<AgentConnect />` | The agent connect page: the pairing, an Approve button and the result |
143
144
  | `useUser()` | `{ status, user, error }` from `/api/ontology/me`, loaded once per session |
144
145
  | `usePermissions()` | `{ isLoading, roles, permissions, can(permission) }` |
145
146
  | `useSession()`, `signOut()` | The Neon Auth session in the browser; sign-out from the browser |
@@ -289,6 +290,77 @@ const { result, validation, edits } = await segmentstream.ontology.lead.actions.
289
290
  structurally, so the app needs neither the ontology SDK nor Effect at runtime.
290
291
  Without a type argument, any entity name is accepted and records are untyped.
291
292
 
293
+ ## Agent connect page
294
+
295
+ The workspace agent answers a chat account it does not know, such as a Telegram user,
296
+ with a link to `/agent/connect?code=<code>`. A signed-in member approves there, and the
297
+ ontology links the account to them: the agent then acts as that member, with their
298
+ current roles, for that account's messages. The proxy signs the page in like any other
299
+ and forbids other sites to frame it.
300
+
301
+ ```tsx
302
+ // src/app/agent/connect/page.tsx, without Tailwind
303
+ import { SegmentStreamApiError, type AgentPairing } from "@segmentstream-cloud/app";
304
+ import { AgentConnect } from "@segmentstream-cloud/app/ui";
305
+ import { segmentstream } from "@/segmentstream";
306
+
307
+ type SearchParams = Promise<Record<string, string | string[] | undefined>>;
308
+
309
+ export default async function ConnectPage({ searchParams }: { searchParams: SearchParams }) {
310
+ const user = await segmentstream.requireUser();
311
+ const { code, connected } = await searchParams;
312
+ let pairing: AgentPairing | null = null;
313
+
314
+ if (typeof code === "string" && typeof connected !== "string") {
315
+ try {
316
+ pairing = await segmentstream.agent.pairing(code);
317
+ } catch (error) {
318
+ // A used or expired code shows "This link has expired"; anything else is an error.
319
+ if (
320
+ !(error instanceof SegmentStreamApiError) ||
321
+ !["not_found", "expired"].includes(error.code)
322
+ ) {
323
+ throw error;
324
+ }
325
+ }
326
+ }
327
+
328
+ return (
329
+ <AgentConnect
330
+ code={typeof code === "string" ? code : undefined}
331
+ pairing={pairing}
332
+ connected={typeof connected === "string" ? connected : undefined}
333
+ member={user.email ?? undefined}
334
+ />
335
+ );
336
+ }
337
+ ```
338
+
339
+ ```ts
340
+ // src/app/api/agent/connect/route.ts
341
+ import { segmentstream } from "@/segmentstream";
342
+
343
+ export const { POST } = segmentstream.agent.connectHandler();
344
+ ```
345
+
346
+ `segmentstream.agent` holds:
347
+
348
+ | Call | Does |
349
+ | ---------------------- | ------------------------------------------------------------------------------- |
350
+ | `pairing(code)` | `{ provider, display, expiresAt }`; throws `not_found` or `expired` |
351
+ | `approvePairing(code)` | Links the account to the signed-in member; returns the `AgentIdentity` |
352
+ | `identities()` | The member's own: `{ id, provider, externalId, display, createdAt, revokedAt }` |
353
+ | `revokeIdentity(id)` | Revokes one of them at once |
354
+ | `connectHandler()` | `{ POST }` for the page's form: same-origin only, takes `code`, answers `303` |
355
+
356
+ The handler sends the browser back to `/agent/connect?connected=<provider>`, or to
357
+ `/agent/connect?expired=1` when the code was used or is older than ten minutes. Other
358
+ refusals, such as `forbidden` for a member the ontology does not admit, throw
359
+ `SegmentStreamApiError`. All calls are server-only and run as the signed-in member;
360
+ the ontology decides every one. The workspace starter renders the page with Tailwind CSS
361
+ and shadcn/ui; apps made from an earlier starter add `src/app/agent/connect/page.tsx`
362
+ and `src/app/api/agent/connect/route.ts` from it.
363
+
292
364
  ## Query the warehouse
293
365
 
294
366
  ```tsx
@@ -0,0 +1,55 @@
1
+ import type { OntologyTransport } from "./ontology.js";
2
+ import { AGENT_CONNECT_HANDLER, AGENT_CONNECT_PAGE } from "./pages.js";
3
+ export { AGENT_CONNECT_HANDLER, AGENT_CONNECT_PAGE };
4
+ /**
5
+ * A chat account asking to be linked to the signed-in member, as the connect page shows it. The
6
+ * workspace agent answers a sender it does not know with the link `/agent/connect?code=<code>`.
7
+ */
8
+ export interface AgentPairing {
9
+ /** The channel the sender wrote on, such as `telegram`. */
10
+ readonly provider: string;
11
+ /** How the channel names the sender, such as `@ada`; `null` when it gave no name. */
12
+ readonly display: string | null;
13
+ /** When the code stops working: ten minutes after the agent issued it. */
14
+ readonly expiresAt: string;
15
+ }
16
+ /** A chat account linked to a member: the agent acts as that member for its messages. */
17
+ export interface AgentIdentity {
18
+ readonly id: string;
19
+ readonly provider: string;
20
+ /** The account's id on the channel, such as a Telegram user id. */
21
+ readonly externalId: string;
22
+ /** How the channel named the account when it was linked; `null` when unknown. */
23
+ readonly display: string | null;
24
+ readonly createdAt: string;
25
+ /** When the member or an administrator revoked it; `null` while it is live. */
26
+ readonly revokedAt: string | null;
27
+ }
28
+ export interface AgentIdentities {
29
+ /**
30
+ * The pairing the connect page at `/agent/connect?code=<code>` shows, for the signed-in member.
31
+ * Throws `SegmentStreamApiError` with `not_found` or `expired` when the code is unknown, used or
32
+ * too old. Server-only.
33
+ */
34
+ readonly pairing: (code: string) => Promise<AgentPairing>;
35
+ /**
36
+ * Links the pairing's chat account to the signed-in member. The ontology decides: it refuses a
37
+ * used or expired code (`not_found`, `expired`) and a member it does not admit (`forbidden`).
38
+ * Server-only.
39
+ */
40
+ readonly approvePairing: (code: string) => Promise<AgentIdentity>;
41
+ /** The signed-in member's own linked chat accounts. Server-only. */
42
+ readonly identities: () => Promise<ReadonlyArray<AgentIdentity>>;
43
+ /** Revokes one of the signed-in member's linked chat accounts at once. Server-only. */
44
+ readonly revokeIdentity: (id: string) => Promise<void>;
45
+ /**
46
+ * The Route Handler the connect page's form posts to, at `app/src/app/api/agent/connect/route.ts`.
47
+ * It takes `code`, approves the pairing and sends the browser back to the connect page with
48
+ * `connected=<provider>`, or with `expired=1` when the code no longer works.
49
+ */
50
+ readonly connectHandler: () => {
51
+ readonly POST: (request: Request) => Promise<Response>;
52
+ };
53
+ }
54
+ /** Pairing and linked chat accounts, over the ontology service as the signed-in member. */
55
+ export declare function createAgentIdentities(transport: OntologyTransport): AgentIdentities;
package/dist/agent.js ADDED
@@ -0,0 +1,202 @@
1
+ import { NextResponse } from "next/server";
2
+ import { isSameOrigin } from "./decision-pages.js";
3
+ import { SegmentStreamApiError, apiErrorFrom } from "./errors.js";
4
+ import { AGENT_CONNECT_HANDLER, AGENT_CONNECT_PAGE } from "./pages.js";
5
+ export { AGENT_CONNECT_HANDLER, AGENT_CONNECT_PAGE };
6
+ /** The error codes of a code that no longer works: the connect page asks for a new link. */
7
+ const GONE_CODES = new Set(["not_found", "expired"]);
8
+ /** The SDK's error code for a refusal whose body names none. */
9
+ const STATUS_CODES = {
10
+ 403: "forbidden",
11
+ 404: "not_found",
12
+ 410: "expired",
13
+ };
14
+ function pairingPath(code, approve) {
15
+ const path = `/agent/pairings/${encodeURIComponent(code)}`;
16
+ return approve ? `${path}/approve` : path;
17
+ }
18
+ function identityPath(id) {
19
+ return id === undefined ? "/agent/identities" : `/agent/identities/${encodeURIComponent(id)}`;
20
+ }
21
+ /** The service's error, with the SDK's codes when its body names none. */
22
+ function agentErrorFrom(status, payload, detail) {
23
+ const error = apiErrorFrom(status, payload, detail);
24
+ if (!error.code.startsWith("http_")) {
25
+ return error;
26
+ }
27
+ const code = STATUS_CODES[status];
28
+ if (code === undefined) {
29
+ return error;
30
+ }
31
+ return new SegmentStreamApiError({
32
+ code,
33
+ status,
34
+ detail: error.detail,
35
+ requestId: error.requestId,
36
+ retryable: error.retryable,
37
+ });
38
+ }
39
+ function invalidResponse(detail) {
40
+ return new SegmentStreamApiError({
41
+ code: "ontology_invalid_response",
42
+ status: 502,
43
+ detail,
44
+ retryable: true,
45
+ });
46
+ }
47
+ function unknownCode() {
48
+ return new SegmentStreamApiError({
49
+ code: "not_found",
50
+ status: 404,
51
+ detail: "The link has no pairing code. Ask the agent for a new link.",
52
+ });
53
+ }
54
+ function fields(payload) {
55
+ return typeof payload === "object" && payload !== null && !Array.isArray(payload)
56
+ ? payload
57
+ : null;
58
+ }
59
+ function optionalString(value) {
60
+ return typeof value === "string" && value !== "" ? value : null;
61
+ }
62
+ /** Reads `{ provider, display, expires_at }` tolerantly: unknown fields are ignored. */
63
+ function parsePairing(payload) {
64
+ const body = fields(payload);
65
+ if (body === null || typeof body.provider !== "string" || typeof body.expires_at !== "string") {
66
+ return null;
67
+ }
68
+ return {
69
+ provider: body.provider,
70
+ display: optionalString(body.display),
71
+ expiresAt: body.expires_at,
72
+ };
73
+ }
74
+ /** Reads `{ id, provider, external_id, display?, created_at, revoked_at? }` tolerantly. */
75
+ function parseIdentity(payload) {
76
+ const body = fields(payload);
77
+ if (body === null ||
78
+ typeof body.id !== "string" ||
79
+ typeof body.provider !== "string" ||
80
+ typeof body.external_id !== "string" ||
81
+ typeof body.created_at !== "string") {
82
+ return null;
83
+ }
84
+ return {
85
+ id: body.id,
86
+ provider: body.provider,
87
+ externalId: body.external_id,
88
+ display: optionalString(body.display),
89
+ createdAt: body.created_at,
90
+ revokedAt: optionalString(body.revoked_at),
91
+ };
92
+ }
93
+ /** The identities in `{ identities: [...] }`, `{ items: [...] }` or a bare array. */
94
+ function identityList(payload) {
95
+ if (Array.isArray(payload)) {
96
+ return payload;
97
+ }
98
+ const body = fields(payload);
99
+ const list = body?.identities ?? body?.items;
100
+ return Array.isArray(list) ? list : null;
101
+ }
102
+ /** Pairing and linked chat accounts, over the ontology service as the signed-in member. */
103
+ export function createAgentIdentities(transport) {
104
+ async function send(path, method) {
105
+ const response = await transport.fetch(path, {
106
+ method,
107
+ headers: { accept: "application/json" },
108
+ });
109
+ const text = await response.text();
110
+ let payload = null;
111
+ try {
112
+ // An empty body, such as a 204 on revocation, carries no payload.
113
+ payload = text === "" ? null : JSON.parse(text);
114
+ }
115
+ catch (error) {
116
+ // The parse failure is the cause, so a malformed answer is told apart from a refusal.
117
+ const refused = agentErrorFrom(response.status, null, "The ontology service refused the request.");
118
+ throw new SegmentStreamApiError({
119
+ code: response.ok ? "ontology_invalid_response" : refused.code,
120
+ status: response.ok ? 502 : refused.status,
121
+ detail: response.ok
122
+ ? "The ontology service answered with a body that is not JSON."
123
+ : refused.detail,
124
+ retryable: response.ok ? true : refused.retryable,
125
+ cause: error,
126
+ });
127
+ }
128
+ if (!response.ok) {
129
+ throw agentErrorFrom(response.status, payload, "The ontology service refused the request.");
130
+ }
131
+ return payload;
132
+ }
133
+ async function approvePairing(code) {
134
+ if (code === "") {
135
+ throw unknownCode();
136
+ }
137
+ const payload = await send(pairingPath(code, true), "POST");
138
+ const identity = parseIdentity(fields(payload)?.identity);
139
+ if (identity === null) {
140
+ throw invalidResponse("The ontology service approved the pairing without an { identity }.");
141
+ }
142
+ return identity;
143
+ }
144
+ return {
145
+ async pairing(code) {
146
+ if (code === "") {
147
+ throw unknownCode();
148
+ }
149
+ const payload = await send(pairingPath(code, false), "GET");
150
+ const pairing = parsePairing(payload);
151
+ if (pairing === null) {
152
+ throw invalidResponse("The ontology service answered without { provider, expires_at }.");
153
+ }
154
+ return pairing;
155
+ },
156
+ approvePairing,
157
+ async identities() {
158
+ const payload = await send(identityPath(), "GET");
159
+ const list = identityList(payload);
160
+ if (list === null) {
161
+ throw invalidResponse("The ontology service answered without a list of identities.");
162
+ }
163
+ return list.flatMap((candidate) => {
164
+ const identity = parseIdentity(candidate);
165
+ return identity === null ? [] : [identity];
166
+ });
167
+ },
168
+ async revokeIdentity(id) {
169
+ if (id === "") {
170
+ throw new SegmentStreamApiError({
171
+ code: "not_found",
172
+ status: 404,
173
+ detail: "No identity to revoke.",
174
+ });
175
+ }
176
+ await send(identityPath(id), "DELETE");
177
+ },
178
+ connectHandler: () => ({
179
+ async POST(request) {
180
+ if (!isSameOrigin(request)) {
181
+ return NextResponse.json({ error: "forbidden", detail: "Connect a chat account from the app's own page." }, { status: 403 });
182
+ }
183
+ const form = await request.formData();
184
+ const code = String(form.get("code") ?? "");
185
+ let identity;
186
+ try {
187
+ identity = await approvePairing(code);
188
+ }
189
+ catch (error) {
190
+ if (error instanceof SegmentStreamApiError && GONE_CODES.has(error.code)) {
191
+ return NextResponse.redirect(new URL(`${AGENT_CONNECT_PAGE}?expired=1`, request.url), 303);
192
+ }
193
+ throw error;
194
+ }
195
+ const connected = new URL(AGENT_CONNECT_PAGE, request.url);
196
+ connected.searchParams.set("connected", identity.provider);
197
+ // 303: the browser loads the connect page with a GET, showing the result.
198
+ return NextResponse.redirect(connected, 303);
199
+ },
200
+ }),
201
+ };
202
+ }
package/dist/app.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Environment, SegmentStreamAppConfig } from "./config.js";
2
+ import type { AgentIdentities } from "./agent.js";
2
3
  import type { WarehouseClient } from "./data.js";
3
4
  import type { SegmentStreamRouteHandlers } from "./handlers.js";
4
5
  import type { AgentAuthorizations } from "./oauth.js";
@@ -52,6 +53,12 @@ export interface SegmentStreamApp<O = unknown> {
52
53
  * member, and the Route Handler that carries out their decision. Server-only.
53
54
  */
54
55
  readonly oauth: AgentAuthorizations;
56
+ /**
57
+ * The workspace agent's chat accounts: the pairing the agent connect page shows the signed-in
58
+ * member, its approval, the member's linked accounts and their revocation, and the Route Handler
59
+ * the page's form posts to. Server-only.
60
+ */
61
+ readonly agent: AgentIdentities;
55
62
  }
56
63
  /** Replaceable capabilities, for tests. */
57
64
  export interface AppDependencies {
package/dist/app.js CHANGED
@@ -1,11 +1,13 @@
1
1
  import { createNeonAuth } from "@neondatabase/auth/next/server";
2
2
  import { NextResponse } from "next/server";
3
3
  import { environmentValue, processEnvironment, resolveConfig, resolveNeonAuth } from "./config.js";
4
+ import { createAgentIdentities } from "./agent.js";
4
5
  import { createDataClient } from "./data.js";
6
+ import { withUnframeablePages } from "./decision-pages.js";
5
7
  import { SegmentStreamApiError } from "./errors.js";
6
8
  import { createHandlers } from "./handlers.js";
7
9
  import { createNeonSession } from "./neon.js";
8
- import { createAgentAuthorizations, withUnframeableConsent } from "./oauth.js";
10
+ import { createAgentAuthorizations } from "./oauth.js";
9
11
  import { createOntologyClient } from "./ontology.js";
10
12
  import { createOntologyTransport } from "./ontology-transport.js";
11
13
  import { createProxy } from "./proxy.js";
@@ -44,7 +46,7 @@ export function createApp(config, dependencies = {}) {
44
46
  });
45
47
  return {
46
48
  authentication: "segmentstream",
47
- proxy: (options) => withUnframeableConsent(createProxy(context, options)),
49
+ proxy: (options) => withUnframeablePages(createProxy(context, options)),
48
50
  authHandlers: notConfiguredHandlers,
49
51
  handlers: () => createHandlers(context),
50
52
  getUser: legacy.getUser,
@@ -57,6 +59,7 @@ export function createApp(config, dependencies = {}) {
57
59
  warehouse: data.warehouse,
58
60
  ontology: createOntologyClient(ontologyTransport),
59
61
  oauth: createAgentAuthorizations(ontologyTransport),
62
+ agent: createAgentIdentities(ontologyTransport),
60
63
  };
61
64
  }
62
65
  const neon = createNeonSession({
@@ -74,7 +77,7 @@ export function createApp(config, dependencies = {}) {
74
77
  });
75
78
  return {
76
79
  authentication: "neon-auth",
77
- proxy: (options) => withUnframeableConsent(neon.proxy(options)),
80
+ proxy: (options) => withUnframeablePages(neon.proxy(options)),
78
81
  authHandlers: neon.authHandlers,
79
82
  handlers: () => createHandlers(context),
80
83
  getUser: neon.getUser,
@@ -86,6 +89,7 @@ export function createApp(config, dependencies = {}) {
86
89
  warehouse: data.warehouse,
87
90
  ontology: createOntologyClient(neon.ontologyTransport),
88
91
  oauth: createAgentAuthorizations(neon.ontologyTransport),
92
+ agent: createAgentIdentities(neon.ontologyTransport),
89
93
  };
90
94
  }
91
95
  /** Under Neon Auth the app has no SegmentStream session to call the API with. */
@@ -0,0 +1,12 @@
1
+ import { type SegmentStreamProxy } from "./proxy.js";
2
+ /**
3
+ * The proxy, with the decision pages unframeable: no other site may show them in a frame and trick
4
+ * a member into clicking Allow or Approve. Every app gets this from the SDK, whatever its Next.js
5
+ * config says.
6
+ */
7
+ export declare function withUnframeablePages(proxy: SegmentStreamProxy): SegmentStreamProxy;
8
+ /**
9
+ * Whether a form post came from the app's own pages. A decision must never be made from another
10
+ * site, which could otherwise approve an agent in the member's name.
11
+ */
12
+ export declare function isSameOrigin(request: Request): boolean;
@@ -0,0 +1,31 @@
1
+ import { AGENT_CONNECT_PAGE, CONSENT_PAGE } from "./pages.js";
2
+ import { matchesPathPrefix } from "./proxy.js";
3
+ /** Pages where a signed-in member lets an agent act for them. */
4
+ const DECISION_PAGES = [CONSENT_PAGE, AGENT_CONNECT_PAGE];
5
+ /**
6
+ * The proxy, with the decision pages unframeable: no other site may show them in a frame and trick
7
+ * a member into clicking Allow or Approve. Every app gets this from the SDK, whatever its Next.js
8
+ * config says.
9
+ */
10
+ export function withUnframeablePages(proxy) {
11
+ return async (request) => {
12
+ const response = await proxy(request);
13
+ const pathname = request.nextUrl.pathname;
14
+ if (DECISION_PAGES.some((page) => matchesPathPrefix(pathname, page))) {
15
+ response.headers.set("x-frame-options", "DENY");
16
+ response.headers.set("content-security-policy", "frame-ancestors 'none'");
17
+ }
18
+ return response;
19
+ };
20
+ }
21
+ /**
22
+ * Whether a form post came from the app's own pages. A decision must never be made from another
23
+ * site, which could otherwise approve an agent in the member's name.
24
+ */
25
+ export function isSameOrigin(request) {
26
+ const origin = request.headers.get("origin");
27
+ if (origin !== null) {
28
+ return origin === new URL(request.url).origin;
29
+ }
30
+ return request.headers.get("sec-fetch-site") === "same-origin";
31
+ }
package/dist/index.d.ts CHANGED
@@ -20,6 +20,8 @@ export { grants, isAllowed } from "./permissions.js";
20
20
  export { sql } from "./parameters.js";
21
21
  export type { AuthenticationMethod, SegmentStreamApp } from "./app.js";
22
22
  export { CONSENT_PAGE } from "./oauth.js";
23
+ export { AGENT_CONNECT_HANDLER, AGENT_CONNECT_PAGE } from "./agent.js";
24
+ export type { AgentIdentities, AgentIdentity, AgentPairing } from "./agent.js";
23
25
  export type { AgentAuthorizationRequest, AgentAuthorizations, AgentScope } from "./oauth.js";
24
26
  export type { ApiIssue } from "./errors.js";
25
27
  export type { CurrentUser } from "./me.js";
@@ -33,3 +35,5 @@ export type { SegmentStreamRouteHandlers, RouteHandler } from "./handlers.js";
33
35
  export type { ProxyOptions, SegmentStreamProxy } from "./proxy.js";
34
36
  export type { AppUser } from "./tokens.js";
35
37
  export { createBlobStorageClient, type BlobUploadTicket, type FileRevision, type FileTransfer, type FileUploadTarget, type UploadSession, type FileUploadOptions, } from "./storage.js";
38
+ /** Whether a form post came from the app itself: the check the agent connect and consent handlers use. */
39
+ export { isSameOrigin } from "./decision-pages.js";
package/dist/index.js CHANGED
@@ -20,4 +20,7 @@ export { NOT_INVITED, SIGN_IN_LINK_INVALID, SegmentStreamApiError } from "./erro
20
20
  export { grants, isAllowed } from "./permissions.js";
21
21
  export { sql } from "./parameters.js";
22
22
  export { CONSENT_PAGE } from "./oauth.js";
23
+ export { AGENT_CONNECT_HANDLER, AGENT_CONNECT_PAGE } from "./agent.js";
23
24
  export { createBlobStorageClient, } from "./storage.js";
25
+ /** Whether a form post came from the app itself: the check the agent connect and consent handlers use. */
26
+ export { isSameOrigin } from "./decision-pages.js";
package/dist/oauth.d.ts CHANGED
@@ -1,12 +1,6 @@
1
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;
2
+ import { CONSENT_PAGE } from "./pages.js";
3
+ export { CONSENT_PAGE };
10
4
  /** Everything the member may do, or only the reads of it. */
11
5
  export type AgentScope = "ontology" | "ontology:read";
12
6
  /** An agent asking to act for the signed-in member, as the consent page shows it. */
package/dist/oauth.js CHANGED
@@ -1,22 +1,8 @@
1
1
  import { NextResponse } from "next/server";
2
+ import { isSameOrigin } from "./decision-pages.js";
2
3
  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
- }
4
+ import { CONSENT_PAGE } from "./pages.js";
5
+ export { CONSENT_PAGE };
20
6
  function parseRequest(payload) {
21
7
  if (typeof payload !== "object" || payload === null) {
22
8
  return null;
@@ -45,17 +31,6 @@ function parseRequest(payload) {
45
31
  expiresAt: body.expires_at,
46
32
  };
47
33
  }
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
34
  function requestPath(id, decision) {
60
35
  const path = `/oauth/requests/${encodeURIComponent(id)}`;
61
36
  return decision === undefined ? path : `${path}/${decision}`;
@@ -82,7 +57,7 @@ export function createAgentAuthorizations(transport) {
82
57
  },
83
58
  consentHandler: () => ({
84
59
  async POST(request) {
85
- if (!sameOrigin(request)) {
60
+ if (!isSameOrigin(request)) {
86
61
  return NextResponse.json({ error: "forbidden", detail: "Decide on an agent from the app's own page." }, { status: 403 });
87
62
  }
88
63
  const form = await request.formData();
@@ -0,0 +1,6 @@
1
+ /** The app's page where a signed-in member decides whether an agent may act for them. */
2
+ export declare const CONSENT_PAGE = "/oauth/consent";
3
+ /** The app's page where a signed-in member links their chat account to the workspace agent. */
4
+ export declare const AGENT_CONNECT_PAGE = "/agent/connect";
5
+ /** The Route Handler the agent connect page's form posts to. */
6
+ export declare const AGENT_CONNECT_HANDLER = "/api/agent/connect";
package/dist/pages.js ADDED
@@ -0,0 +1,6 @@
1
+ /** The app's page where a signed-in member decides whether an agent may act for them. */
2
+ export const CONSENT_PAGE = "/oauth/consent";
3
+ /** The app's page where a signed-in member links their chat account to the workspace agent. */
4
+ export const AGENT_CONNECT_PAGE = "/agent/connect";
5
+ /** The Route Handler the agent connect page's form posts to. */
6
+ export const AGENT_CONNECT_HANDLER = "/api/agent/connect";
@@ -0,0 +1,18 @@
1
+ import type { AgentPairing } from "../agent.js";
2
+ export interface AgentConnectProps {
3
+ /** The page's `code` query parameter, from the agent's link. */
4
+ readonly code: string | undefined;
5
+ /** `segmentstream.agent.pairing(code)`, or `null` when the code no longer works. */
6
+ readonly pairing: AgentPairing | null;
7
+ /** The page's `connected` query parameter: the channel just linked, after approval. */
8
+ readonly connected?: string | undefined;
9
+ /** Who is signed in, such as their email, so they see whom the chat account will act as. */
10
+ readonly member?: string | undefined;
11
+ /** Where the form posts. Defaults to `/api/agent/connect`, `segmentstream.agent.connectHandler()`. */
12
+ readonly action?: string;
13
+ }
14
+ /**
15
+ * The agent connect page in the SDK's neutral styles: what asks to be linked, an Approve button and
16
+ * the result. Render it from the Server Component at `/agent/connect` with the pairing it read.
17
+ */
18
+ export declare function AgentConnect({ code, pairing, connected, member, action, }: AgentConnectProps): import("react").JSX.Element;
@@ -0,0 +1,23 @@
1
+ "use client";
2
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { useId } from "react";
4
+ import { AGENT_CONNECT_HANDLER } from "../pages.js";
5
+ /**
6
+ * The agent connect page in the SDK's neutral styles: what asks to be linked, an Approve button and
7
+ * the result. Render it from the Server Component at `/agent/connect` with the pairing it read.
8
+ */
9
+ export function AgentConnect({ code, pairing, connected, member = "you", action = AGENT_CONNECT_HANDLER, }) {
10
+ const titleId = `${useId()}-title`;
11
+ if (connected !== undefined) {
12
+ return (_jsxs("section", { className: "ss-card", "aria-labelledby": titleId, children: [_jsxs("h1", { id: titleId, className: "ss-title", children: [providerName(connected), " is connected"] }), _jsxs("p", { className: "ss-text", children: ["The agent now acts as ", _jsx("strong", { children: member }), " when you write to it there. Go back to the chat and send your message again."] })] }));
13
+ }
14
+ if (code === undefined || code === "" || pairing === null) {
15
+ return (_jsxs("section", { className: "ss-card", "aria-labelledby": titleId, children: [_jsx("h1", { id: titleId, className: "ss-title", children: "This link has expired" }), _jsx("p", { className: "ss-text", children: "The link was already used or is more than ten minutes old. Write to the agent again for a new one." })] }));
16
+ }
17
+ const provider = providerName(pairing.provider);
18
+ return (_jsxs("section", { className: "ss-card", "aria-labelledby": titleId, children: [_jsxs("h1", { id: titleId, className: "ss-title", children: ["Connect ", provider, " to this app?"] }), _jsxs("p", { className: "ss-text", children: [pairing.display === null ? (`A ${provider} account`) : (_jsxs(_Fragment, { children: [_jsx("strong", { children: pairing.display }), " on ", provider] })), " ", "asks to talk to the workspace agent as ", _jsx("strong", { children: member }), ". The agent answers it with your permissions, and history records its changes under your name. Approve only if this is your own account."] }), _jsxs("form", { className: "ss-form", method: "post", action: action, children: [_jsx("input", { type: "hidden", name: "code", value: code }), _jsx("button", { type: "submit", className: "ss-button", children: "Approve" })] })] }));
19
+ }
20
+ /** `telegram` reads as `Telegram`. */
21
+ function providerName(provider) {
22
+ return provider.charAt(0).toUpperCase() + provider.slice(1);
23
+ }
@@ -1,5 +1,7 @@
1
1
  export { Can, SignedIn, SignedOut, UserMenu, signOut } from "./components.js";
2
2
  export type { CanProps, ChildrenProps, UserMenuProps } from "./components.js";
3
+ export { AgentConnect } from "./agent-connect.js";
4
+ export type { AgentConnectProps } from "./agent-connect.js";
3
5
  export { usePermissions, useSession, useUser } from "./hooks.js";
4
6
  export type { Permissions } from "./hooks.js";
5
7
  export { SignIn } from "./sign-in.js";
@@ -9,4 +11,5 @@ export type { SignInController, SignInErrorCode, SignInNoticeCode, SignInOptions
9
11
  export type { SessionState, SessionUser, UserState } from "./store.js";
10
12
  export { isAllowed } from "../permissions.js";
11
13
  export type { CurrentUser } from "../me.js";
14
+ export type { AgentPairing } from "../agent.js";
12
15
  export { SegmentStreamApiError, NOT_INVITED, SIGN_IN_LINK_INVALID } from "../errors.js";
package/dist/ui/index.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // layout, when rendering the components: `import "@segmentstream-cloud/app/ui/styles.css";`. The
4
4
  // hooks, `useSignIn()` among them, need no stylesheet.
5
5
  export { Can, SignedIn, SignedOut, UserMenu, signOut } from "./components.js";
6
+ export { AgentConnect } from "./agent-connect.js";
6
7
  export { usePermissions, useSession, useUser } from "./hooks.js";
7
8
  export { SignIn } from "./sign-in.js";
8
9
  export { SIGN_IN_ERROR_MESSAGES, signInErrorCode, signInErrorMessage, useSignIn, } from "./use-sign-in.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segmentstream-cloud/app",
3
- "version": "0.10.0",
3
+ "version": "0.11.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",