@segmentstream-cloud/app 0.9.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
@@ -334,9 +406,79 @@ export default async function Page() {
334
406
  - Values arrive as JSON: safe integers as numbers, larger integers and decimals as strings,
335
407
  timestamps as ISO strings, records and arrays as JSON strings.
336
408
 
337
- `segmentstream.ontology.<entity>.aggregate(request)` uses the server-held workspace app
338
- credential and supports Neon Auth apps. Stored record operations use the ontology user token.
339
- `segmentstream.fetch(path)` still requires the legacy platform session.
409
+ One authorization model covers the ontology: every `segmentstream.ontology` call, aggregates
410
+ included, runs as the signed-in user through the ontology service, which checks their
411
+ permissions. `segmentstream.ontology.<entity>.aggregate(request)` posts to
412
+ `/api/ontology/<entity>/aggregate` and needs read permission on the entities it reaches.
413
+ `segmentstream.warehouse` is the only call that uses the workspace app credential, and it
414
+ reaches the whole workspace warehouse. `segmentstream.fetch(path)` still requires the legacy
415
+ platform session. Aggregates through the service need the workspace's ontology deployed with
416
+ `@segmentstream-cloud/ontology` 0.13.0 or later.
417
+
418
+ Since 0.10.0, dimensions, filters and `orderBy` accept relationship paths, typed from the
419
+ ontology's relationships:
420
+
421
+ ```ts
422
+ const { rows, execution } = await segmentstream.ontology.order.aggregate({
423
+ dimensions: ["customer.segment"],
424
+ measures: ["orders"],
425
+ dateRange: { from: "2026-09-01", to: "2026-09-30" },
426
+ });
427
+ ```
428
+
429
+ - A path is `<relationship>.<property>`, through one or two to-one relationships. Paths into
430
+ a stored target include `id`; json and blob properties are left out. A thunk target types
431
+ its paths as `` `customer.${string}` ``. Each segment converts to snake_case on the wire,
432
+ and rows come back keyed by the camelCase path. These names are relative to the entity and
433
+ convert as record fields do: `line_2` reads as `line2`.
434
+ - `execution.warnings` reports keys that matched more than one related row; totals are
435
+ unaffected. `execution.sources` gives, for each stored entity read through replication,
436
+ the time its data is current to, once known. Both are absent when the query produced none.
437
+ - 0.10.0 requires the platform release that joins stored targets through replication.
438
+ - See [ontology aggregates](../../docs/ontology-aggregates.md#relationship-paths).
439
+
440
+ Since 0.10.0, `segmentstream.ontology.aggregate(request)` asks for numbers about the whole
441
+ model: measures of several entities, warehouse-backed or stored, grouped by properties they all
442
+ reach. Look at or change records through `find` and actions; count, sum or compare through
443
+ `aggregate`.
444
+
445
+ ```ts
446
+ const { fields, rows, execution } = await segmentstream.ontology.aggregate({
447
+ measures: ["trendtoysSales.revenue", "trendtoysStock.onHand"],
448
+ dimensions: ["trendtoysProduct.assortmentStatus"],
449
+ filters: [
450
+ {
451
+ dimension: "trendtoysSales.orderDate",
452
+ operator: "between",
453
+ values: ["2026-09-01", "2026-09-30"],
454
+ },
455
+ { dimension: "trendtoysSales.currency", operator: "equals", value: "RUB" },
456
+ ],
457
+ orderBy: [{ measure: "trendtoysSales.revenue", direction: "desc" }],
458
+ });
459
+ ```
460
+
461
+ - Names are `entity.measure` and `entity.property`, each segment in camelCase, typed from the
462
+ ontology. An `_` before a digit stays, so an entity's declared name converts back:
463
+ `sales_2025.revenue`. This differs from the per-entity call and records, which drop it. Fields
464
+ and rows are keyed by these names.
465
+ - When an entity reaches a property's entity in more than one way, write a route from it:
466
+ `trendtoysSales.returnedProduct.brand`. Its column is still keyed by the property's own name,
467
+ `trendtoysProduct.brand`.
468
+ - It posts to the ontology service's `/api/ontology/aggregate` as the signed-in user, who needs
469
+ read permission on every entity the request reaches. Previews and local development answer
470
+ `aggregate_unavailable`.
471
+ - Date bounds are `between` filters, inclusive. Each partitioned entity needs one on its own
472
+ partition property. Currency measures need their entity filtered to one currency, the same
473
+ for every entity.
474
+ - A measure field's `filters` lists the filters applied to it, and `execution.warnings` names
475
+ each filter that did not reach a measure's entity. `additive: false` marks a measure whose
476
+ rows don't add up to a total.
477
+ - Failures throw `SegmentStreamApiError` with the service's `code`: `invalid_request`,
478
+ `invalid_aggregate_query`, `forbidden`, `rate_limited` (30 aggregates a minute per user),
479
+ `aggregate_unavailable`, `aggregate_upstream_unavailable`, `ontology_version_mismatch` or
480
+ `aggregate_scope_mismatch`. See
481
+ [ontology aggregates](../../docs/ontology-aggregates.md#errors).
340
482
 
341
483
  ## How requests are handled
342
484
 
@@ -395,7 +537,7 @@ Without `NEON_AUTH_BASE_URL`, 0.3 behaves exactly as 0.2, so existing apps keep
395
537
  working: members of the workspace's organization sign in with their SegmentStream
396
538
  account through `/api/segmentstream/[...path]/route.ts`
397
539
  (`export const { GET, POST } = segmentstream.handlers();`), the proxy redirects to
398
- SegmentStream, and `fetch` acts as the member; warehouse queries and aggregates prefer the workspace app credential. In that mode
540
+ SegmentStream, and `fetch` acts as the member; warehouse queries prefer the workspace app credential. In that mode
399
541
  `roles` and `permissions` are empty, `can()` is `false`, `authHandlers()` answers
400
542
  `404 neon_auth_not_configured`, and `segmentstream.ontology` sends the member's app
401
543
  token, which the ontology service accepts while workspaces move to Neon Auth. Keep the
@@ -422,3 +564,18 @@ Apache-2.0
422
564
  ## Upgrading to 0.5
423
565
 
424
566
  Replace segmentstream.semantic with typed warehouse-backed entity aggregates. See [ontology aggregates](../../docs/ontology-aggregates.md). Warehouse-backed clients expose no record CRUD or actions.
567
+
568
+ ## Upgrading to 0.10
569
+
570
+ - Shorthand aggregates accept relationship paths. Their responses may carry
571
+ `execution.warnings` and `execution.sources`.
572
+ - `segmentstream.ontology.aggregate()` is new, and `aggregate` is a reserved entity name.
573
+ - `segmentstream.ontology.<entity>.aggregate()` now runs as the signed-in user through the
574
+ ontology service, like record reads, and no longer uses `SEGMENTSTREAM_APP_CREDENTIAL`. The
575
+ user needs read permission on the entities it reaches.
576
+ - Server code without a signed-in user, such as a scheduled Route Handler, can no longer
577
+ aggregate with the workspace credential. Use `segmentstream.warehouse` there, or call the
578
+ ontology service with an API key.
579
+ - Deploy the workspace's ontology with `@segmentstream-cloud/ontology` 0.13.0 or later. It
580
+ serves `/api/ontology/<entity>/aggregate` and `/api/ontology/aggregate`; earlier versions
581
+ don't.
@@ -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,
@@ -55,8 +57,9 @@ export function createApp(config, dependencies = {}) {
55
57
  getAccessToken: legacy.getAccessToken,
56
58
  fetch: data.fetch,
57
59
  warehouse: data.warehouse,
58
- ontology: createOntologyClient(ontologyTransport, data.aggregateTransport),
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,
@@ -84,8 +87,9 @@ export function createApp(config, dependencies = {}) {
84
87
  getAccessToken: async () => null,
85
88
  fetch: data.fetch,
86
89
  warehouse: data.warehouse,
87
- ontology: createOntologyClient(neon.ontologyTransport, data.aggregateTransport),
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. */
@@ -93,7 +97,7 @@ async function noSegmentStreamSession() {
93
97
  throw new SegmentStreamApiError({
94
98
  code: "app_credential_missing",
95
99
  status: 401,
96
- detail: "Apps that sign in with Neon Auth have no SegmentStream session. Warehouse queries and ontology aggregates use the workspace app credential, SEGMENTSTREAM_APP_CREDENTIAL, which is not set; SegmentStream sets it on deploy. Other SegmentStream API calls are not available to these apps yet.",
100
+ detail: "Apps that sign in with Neon Auth have no SegmentStream session. Warehouse queries use the workspace app credential, SEGMENTSTREAM_APP_CREDENTIAL, which is not set; SegmentStream sets it on deploy. Other SegmentStream API calls are not available to these apps yet.",
97
101
  });
98
102
  }
99
103
  function notConfiguredHandlers() {
package/dist/data.d.ts CHANGED
@@ -43,34 +43,71 @@ export interface WarehouseDryRun {
43
43
  readonly bytesProcessed: string | null;
44
44
  readonly maximumBytesBilled: string;
45
45
  }
46
- export interface AggregateRequest<Property extends string = string, Measure extends string = string, Temporal extends Property = Property> {
47
- readonly dimensions: readonly Property[];
46
+ /**
47
+ * An aggregate query. Dimensions, filters and ordering name the entity's own properties, or a
48
+ * related entity's property through to-one relationships: `Path`, such as `product.brand`.
49
+ */
50
+ export interface AggregateRequest<Property extends string = string, Measure extends string = string, Temporal extends Property = Property, Path extends string = never> {
51
+ readonly dimensions: readonly (Property | Path)[];
48
52
  readonly measures: readonly Measure[];
49
53
  readonly dateRange?: {
50
54
  readonly from: string;
51
55
  readonly to: string;
52
56
  };
53
- readonly granularityOf?: Temporal;
57
+ /** The selected date or timestamp dimension to bucket, which may be a related one. */
58
+ readonly granularityOf?: Temporal | Path;
54
59
  readonly granularity?: "day" | "week" | "month";
55
60
  readonly filters?: ReadonlyArray<{
56
- readonly dimension: Property;
61
+ readonly dimension: Property | Path;
57
62
  readonly operator: "equals" | "in" | "isNull" | "isNotNull";
58
63
  readonly value?: string | number | boolean;
59
64
  readonly values?: ReadonlyArray<string | number | boolean>;
60
65
  }>;
61
66
  readonly orderBy?: ReadonlyArray<{
62
67
  readonly measure?: Measure;
63
- readonly dimension?: Property;
68
+ readonly dimension?: Property | Path;
64
69
  readonly direction: "asc" | "desc";
65
70
  }>;
66
71
  readonly limit?: number;
67
72
  }
73
+ /** A filter of a model-level aggregate. `between` takes two inclusive `values`, the lower first. */
74
+ export interface ModelAggregateFilter<Dimension extends string = string> {
75
+ readonly dimension: Dimension;
76
+ readonly operator: "equals" | "in" | "isNull" | "isNotNull" | "between";
77
+ readonly value?: string | number | boolean;
78
+ readonly values?: ReadonlyArray<string | number | boolean>;
79
+ }
80
+ /**
81
+ * A model-level aggregate: measures of one or more entities, as `entity.measure`, grouped by
82
+ * `entity.property` dimensions. A dimension may instead be a route from a measure's entity,
83
+ * `entity.relationship.property`, which picks the way that entity reaches the property. Date bounds
84
+ * are `between` filters.
85
+ */
86
+ export interface ModelAggregateRequest<Measure extends string = string, Dimension extends string = string> {
87
+ readonly measures: readonly Measure[];
88
+ readonly dimensions?: readonly Dimension[];
89
+ readonly filters?: ReadonlyArray<ModelAggregateFilter<Dimension>>;
90
+ readonly granularity?: "day" | "week" | "month";
91
+ /** The requested date or timestamp dimension to bucket by `granularity`. */
92
+ readonly granularityOf?: Dimension;
93
+ readonly orderBy?: ReadonlyArray<{
94
+ readonly measure?: Measure;
95
+ readonly dimension?: Dimension;
96
+ readonly direction: "asc" | "desc";
97
+ }>;
98
+ /** Rows to return, 1–1000; 100 by default. */
99
+ readonly limit?: number;
100
+ }
68
101
  export interface AggregateField {
69
102
  readonly name: string;
70
103
  readonly label: string;
71
104
  readonly type: string;
72
105
  readonly unit?: string;
73
106
  readonly format?: string;
107
+ /** On a measure: the filters applied to it. A filter its entity cannot reach is not listed. */
108
+ readonly filters?: readonly string[];
109
+ /** `false` on a measure whose rows don't add up to a total, such as a distinct count or a ratio. */
110
+ readonly additive?: boolean;
74
111
  }
75
112
  export interface AggregateResult<Row = Record<string, string | boolean | null>> {
76
113
  readonly deploymentId: string | null;
@@ -82,6 +119,16 @@ export interface AggregateResult<Row = Record<string, string | boolean | null>>
82
119
  readonly elapsedMs: number;
83
120
  readonly bytesProcessed?: string;
84
121
  readonly bytesBilled?: string;
122
+ /**
123
+ * Data problems the query met, for example a join key that matched more than one record, and
124
+ * filters that did not apply to some measure's entity.
125
+ */
126
+ readonly warnings?: readonly string[];
127
+ /** The stored entities a query read, each with the time up to which its copy is complete. */
128
+ readonly sources?: ReadonlyArray<{
129
+ readonly entity: string;
130
+ readonly asOf: string;
131
+ }>;
85
132
  };
86
133
  }
87
134
  export interface WarehouseClient {
@@ -111,7 +158,4 @@ export interface DataClientDependencies {
111
158
  export declare function createDataClient(dependencies: DataClientDependencies): {
112
159
  fetch: (path: string, init?: RequestInit) => Promise<Response>;
113
160
  warehouse: WarehouseClient;
114
- aggregateTransport: {
115
- fetch: (path: string, init: RequestInit) => Promise<Response>;
116
- };
117
161
  };
package/dist/data.js CHANGED
@@ -154,14 +154,7 @@ export function createDataClient(dependencies) {
154
154
  };
155
155
  },
156
156
  };
157
- const aggregateTransport = {
158
- fetch: (path, init) => {
159
- if (!/^\/[a-z][a-z0-9_]*\/aggregate$/.test(path))
160
- throw new TypeError("Invalid aggregate path.");
161
- return authorizedFetch(`${workspace}/entities${path}`, init, warehouseToken);
162
- },
163
- };
164
- return { fetch: apiFetch, warehouse, aggregateTransport };
157
+ return { fetch: apiFetch, warehouse };
165
158
  }
166
159
  function bytesLimit(value) {
167
160
  if (value === undefined) {
@@ -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,16 +20,20 @@ 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";
26
28
  export type { AuthRouteHandler, AuthRouteHandlers, SignOutOptions } from "./neon.js";
27
- export type { ActionCall, ActionOptions, ActionResponse, Change, Edit, EditKind, EntityClient, FieldFilter, GeneratedActions, HistoryEntry, HistoryOptions, HistoryPage, JsonRecord, JsonValue, ManyFilter, OntologyClient, AggregateEntityClient, OntologyPage, OntologyQuery, OrderBy, RequestOptions, UntypedOntologyClient, ValidationIssue, Where, } from "./ontology.js";
29
+ export type { ActionCall, ActionOptions, ActionResponse, Change, Edit, EditKind, EntityClient, FieldFilter, GeneratedActions, HistoryEntry, HistoryOptions, HistoryPage, JsonRecord, JsonValue, ManyFilter, ModelAggregateClient, OntologyClient, AggregateEntityClient, OntologyPage, OntologyQuery, OrderBy, RequestOptions, UntypedOntologyClient, ValidationIssue, Where, } from "./ontology.js";
28
30
  export type { PermissionHolder } from "./permissions.js";
29
- export type { AggregateField, AggregateRequest, AggregateResult, WarehouseClient, WarehouseColumn, WarehouseDryRun, WarehouseQueryOptions, WarehouseReadOptions, WarehouseQueryResult, WarehouseRow, WarehouseValue, } from "./data.js";
31
+ export type { AggregateField, AggregateRequest, AggregateResult, ModelAggregateFilter, ModelAggregateRequest, WarehouseClient, WarehouseColumn, WarehouseDryRun, WarehouseQueryOptions, WarehouseReadOptions, WarehouseQueryResult, WarehouseRow, WarehouseValue, } from "./data.js";
30
32
  export type { ParameterType, QueryParameterValue, QueryParameters, TypedParameter, } from "./parameters.js";
31
33
  export type { SegmentStreamAppConfig } from "./config.js";
32
34
  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();
@@ -1,3 +1,10 @@
1
1
  /** Convert each character once, including acronym boundaries such as HTTPClient. */
2
2
  export declare function toSnakeCase(name: string): string;
3
3
  export declare function toCamelCase(name: string): string;
4
+ /**
5
+ * camelCase that keeps an `_` before a digit, so `toSnakeCase` restores the name: `sales_2025`
6
+ * stays `sales_2025` and `wb2_sales` becomes `wb2Sales`. Model-level aggregate names, whose first
7
+ * segment is a declared snake_case entity name, use it; records and per-entity aggregates keep
8
+ * `toCamelCase`.
9
+ */
10
+ export declare function toReversibleCamelCase(name: string): string;
@@ -24,3 +24,12 @@ export function toSnakeCase(name) {
24
24
  export function toCamelCase(name) {
25
25
  return name.replace(/_([a-z0-9])/g, (_, letter) => letter.toUpperCase());
26
26
  }
27
+ /**
28
+ * camelCase that keeps an `_` before a digit, so `toSnakeCase` restores the name: `sales_2025`
29
+ * stays `sales_2025` and `wb2_sales` becomes `wb2Sales`. Model-level aggregate names, whose first
30
+ * segment is a declared snake_case entity name, use it; records and per-entity aggregates keep
31
+ * `toCamelCase`.
32
+ */
33
+ export function toReversibleCamelCase(name) {
34
+ return name.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
35
+ }
@@ -11,6 +11,8 @@ interface CatalogRelationship {
11
11
  readonly to: string;
12
12
  readonly inverse: string;
13
13
  readonly cardinality: string;
14
+ /** Present when either end is warehouse-backed: the relationship is matched by value. */
15
+ readonly on?: unknown;
14
16
  }
15
17
  interface CatalogEntity {
16
18
  readonly name: string;
@@ -16,6 +16,13 @@ const SYSTEM_FIELDS = ["id", "created_at", "updated_at", "created_by", "updated_
16
16
  function isRecord(value) {
17
17
  return value !== null && typeof value === "object" && !Array.isArray(value);
18
18
  }
19
+ /**
20
+ * A relationship with a warehouse-backed end is matched by value through `on`: it has no
21
+ * `<name>_id` field, and the ontology service cannot traverse or filter through it.
22
+ */
23
+ function isValueMatched(relationship) {
24
+ return relationship.on !== undefined && relationship.on !== null;
25
+ }
19
26
  /** Catalog metadata distinguishes relationship filters from opaque JSON property values. */
20
27
  export async function encodeWhere(input) {
21
28
  const { where, entity, getCatalog } = input;
@@ -51,12 +58,14 @@ function whereFromCatalog(input) {
51
58
  }
52
59
  function relationshipTarget(input) {
53
60
  const { field, entity, definition, catalog } = input;
54
- const forward = definition?.relationships.find((candidate) => candidate.name === field);
61
+ const forward = definition?.relationships.find((candidate) => candidate.name === field && !isValueMatched(candidate));
55
62
  if (forward !== undefined) {
56
63
  return { entity: forward.to, many: forward.cardinality === "many" };
57
64
  }
58
65
  for (const candidate of catalog.entities) {
59
- const inverse = candidate.relationships.find((relationship) => relationship.to === entity && relationship.inverse === field);
66
+ const inverse = candidate.relationships.find((relationship) => relationship.to === entity &&
67
+ relationship.inverse === field &&
68
+ !isValueMatched(relationship));
60
69
  if (inverse !== undefined) {
61
70
  return { entity: candidate.name, many: true };
62
71
  }
@@ -79,7 +88,9 @@ function isGeneratedField(field, definition) {
79
88
  if (SYSTEM_FIELDS.includes(field)) {
80
89
  return true;
81
90
  }
82
- return (definition?.relationships.some((relationship) => relationship.cardinality === "one" && `${relationship.name}_id` === field) ?? false);
91
+ return (definition?.relationships.some((relationship) => relationship.cardinality === "one" &&
92
+ !isValueMatched(relationship) &&
93
+ `${relationship.name}_id` === field) ?? false);
83
94
  }
84
95
  function fieldFilterToWire(input) {
85
96
  const { field, condition, definition } = input;
@@ -1,4 +1,4 @@
1
- import type { AggregateRequest, AggregateResult } from "./data.js";
1
+ import type { AggregateRequest, AggregateResult, ModelAggregateRequest } from "./data.js";
2
2
  /**
3
3
  * The typed client for the app's ontology service, `/api/ontology`. The app imports its ontology as
4
4
  * types only (`import type ontology from "../../ontology"`) and passes `typeof ontology` to
@@ -191,31 +191,99 @@ type ActionClients<A> = {
191
191
  type PropertiesOf<E> = E extends {
192
192
  readonly properties: infer P;
193
193
  } ? P : never;
194
+ /** Declared names only: a definition typed as any `Measures` names none the client can check. */
195
+ type DeclaredNames<M> = string extends keyof M ? never : keyof M & string;
196
+ /** A warehouse-backed entity's `measures`, or the measures of a stored entity's definition. */
194
197
  type MeasuresOf<E> = E extends {
195
198
  readonly measures: infer M;
196
- } ? keyof M & string : never;
197
- export interface AggregateEntityClient<Property extends string = string, Measure extends string = string, Temporal extends Property = Property> {
198
- readonly aggregate: (request: AggregateRequest<Property, Measure, Temporal>, options?: RequestOptions) => Promise<AggregateResult>;
199
- }
200
- type ClientOf<E> = E extends {
199
+ } ? keyof M & string : E extends {
200
+ readonly definition: {
201
+ readonly measures?: infer M;
202
+ };
203
+ } ? DeclaredNames<NonNullable<M>> : never;
204
+ type RelationshipsOf<E> = E extends {
205
+ readonly relationships: infer R;
206
+ } ? R : never;
207
+ type IsWarehouseBacked<E> = E extends {
201
208
  readonly definition: {
202
209
  readonly source: object;
203
210
  };
204
- } ? AggregateEntityClient<keyof PropertiesOf<E> & string, MeasuresOf<E>> : EntityClient<RecordOf<E>, ActionClients<ActionsOf<E>>>;
211
+ } ? true : false;
212
+ /**
213
+ * A `rel.one`, which aggregates may follow. Every relationship of a warehouse-backed entity is
214
+ * matched by value; a stored entity's may also be keyed (`<relationship>_id` joined to `id`).
215
+ */
216
+ interface ToOne {
217
+ readonly cardinality: "one";
218
+ }
219
+ /** The target's name, or `string` when a thunk erased it or the ontology SDK predates the name. */
220
+ type TargetNameOf<R> = R extends {
221
+ readonly "~ontology.target": infer Name extends string;
222
+ } ? Name : string;
223
+ /** The entity of `O` named `Name`. `User` is not replicated, so aggregates never reach it. */
224
+ type EntityNamed<O, Name extends string> = Name extends "user" ? never : Extract<EntitiesOf<O>, {
225
+ readonly name: Name;
226
+ }>;
227
+ /** Properties whose values are scalars. json and blob values are objects, which aggregates skip. */
228
+ type ScalarPropertiesOf<T> = {
229
+ [K in keyof PropertiesOf<T>]-?: PropertiesOf<T>[K] extends {
230
+ readonly Type: infer V;
231
+ } ? [Extract<V, object>] extends [never] ? K : never : K;
232
+ }[keyof PropertiesOf<T>] & string;
233
+ /** What a path may end on: the target's scalar properties, and `id` when the target is stored. */
234
+ type FieldsOf<T> = ScalarPropertiesOf<T> | (IsWarehouseBacked<T> extends true ? never : "id");
235
+ /** The rest of a path after reaching `T`: a field, or one more relationship when `Again`. */
236
+ type PathEnds<T, O, Again extends boolean> = [T] extends [never] ? never : FieldsOf<T> | (Again extends true ? PathsFrom<T, O, false> : never);
237
+ type PathsFrom<E, O, Again extends boolean> = {
238
+ [K in keyof RelationshipsOf<E> & string]: RelationshipsOf<E>[K] extends ToOne ? string extends TargetNameOf<RelationshipsOf<E>[K]> ? `${K}.${string}` : `${K}.${PathEnds<EntityNamed<O, TargetNameOf<RelationshipsOf<E>[K]>>, O, Again>}` : never;
239
+ }[keyof RelationshipsOf<E> & string];
240
+ /**
241
+ * Related properties of `E` as aggregate paths through at most two `rel.one` relationships:
242
+ * `product.brand`, `product.brand.name`. A thunk target erases its entity, so any path may follow
243
+ * its relationship: `` `${relationship}.${string}` ``.
244
+ */
245
+ type PathsOf<E, O> = PathsFrom<E, O, true>;
246
+ type Digit = "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
247
+ /**
248
+ * A snake_case entity name in camelCase, keeping an `_` before a digit so the name converts back:
249
+ * `sales_2025` stays, `wb2_sales` is `wb2Sales`. The client decodes model-level names the same way.
250
+ */
251
+ type CamelCase<S extends string> = S extends `${infer Head}_${infer Tail}` ? Tail extends `${Digit}${string}` ? `${Head}_${CamelCase<Tail>}` : `${Head}${Capitalize<CamelCase<Tail>>}` : S;
252
+ /** `entity.measure` for every measure of every entity, warehouse-backed or stored. */
253
+ type ModelMeasuresOf<E> = E extends unknown ? `${CamelCase<NameOf<E>>}.${MeasuresOf<E>}` : never;
254
+ /** A route picks how an entity with measures reaches a property, so only such entities start one. */
255
+ type RoutesOf<E, O> = [MeasuresOf<E>] extends [never] ? never : PathsOf<E, O>;
256
+ /**
257
+ * `entity.property` for every entity's scalar properties, and `id` for stored entities, plus the
258
+ * routes `entity.relationship.property` from each entity with measures.
259
+ */
260
+ type ModelDimensionsOf<E, O> = E extends unknown ? `${CamelCase<NameOf<E>>}.${FieldsOf<E> | RoutesOf<E, O>}` : never;
261
+ /**
262
+ * `segmentstream.ontology.aggregate`: `POST /api/ontology/aggregate`, as the signed-in user, who
263
+ * needs read permission on every entity the request reaches.
264
+ */
265
+ export interface ModelAggregateClient<Measure extends string = string, Dimension extends string = string> {
266
+ readonly aggregate: (request: ModelAggregateRequest<Measure, Dimension>, options?: RequestOptions) => Promise<AggregateResult>;
267
+ }
268
+ export interface AggregateEntityClient<Property extends string = string, Measure extends string = string, Temporal extends Property = Property, Path extends string = never> {
269
+ readonly aggregate: (request: AggregateRequest<Property, Measure, Temporal, Path>, options?: RequestOptions) => Promise<AggregateResult>;
270
+ }
271
+ type ClientOf<E, O> = IsWarehouseBacked<E> extends true ? AggregateEntityClient<keyof PropertiesOf<E> & string, MeasuresOf<E>, keyof PropertiesOf<E> & string, PathsOf<E, O>> : EntityClient<RecordOf<E>, ActionClients<ActionsOf<E>>>;
205
272
  type TypedOntologyClient<O> = {
206
273
  readonly [N in NameOf<EntitiesOf<O>>]: ClientOf<Extract<EntitiesOf<O>, {
207
274
  readonly name: N;
208
- }>>;
209
- };
275
+ }>, O>;
276
+ } & ModelAggregateClient<ModelMeasuresOf<EntitiesOf<O>>, ModelDimensionsOf<EntitiesOf<O>, O>>;
210
277
  /** A client for any entity, when the app passes no ontology type. */
211
- export type UntypedOntologyClient = {
278
+ export type UntypedOntologyClient = ModelAggregateClient & {
212
279
  readonly [entity: string]: EntityClient<JsonRecord, {
213
280
  readonly [action: string]: ActionCall<JsonRecord, JsonValue>;
214
281
  }> & AggregateEntityClient;
215
282
  };
216
283
  /**
217
284
  * `segmentstream.ontology`: one client per entity of `O`, the type of the app's ontology
218
- * (`typeof ontology`). Without it, any entity name is accepted and records are untyped.
285
+ * (`typeof ontology`), and `aggregate` across them. `aggregate` is a reserved entity name. Without
286
+ * `O`, any entity name is accepted and records are untyped.
219
287
  */
220
288
  export type OntologyClient<O = unknown> = unknown extends O ? UntypedOntologyClient : TypedOntologyClient<O>;
221
289
  /** Sends one ontology request with the caller's credentials. Paths are below `/api/ontology`. */
@@ -226,7 +294,7 @@ export interface OntologyTransport {
226
294
  * Entities and actions exist only in the app's types, so the client resolves them by name when
227
295
  * they are used: `ontology.lead.actions.changeStatus(…)` posts to `lead/actions/change_status`.
228
296
  */
229
- export declare function createOntologyClient<O>(transport: OntologyTransport, aggregateTransport?: OntologyTransport): OntologyClient<O>;
297
+ export declare function createOntologyClient<O>(transport: OntologyTransport): OntologyClient<O>;
230
298
  /** `find` sends the query as URL parameters: `where` as JSON, `orderBy` as `field:direction`. */
231
299
  export declare function findParameters(query: OntologyQuery<JsonRecord>): URLSearchParams;
232
300
  export {};
package/dist/ontology.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { SegmentStreamApiError, apiErrorFrom, readJson } from "./errors.js";
2
2
  import { actionInputToWire, encodeWhere, editsFromWire, queryToWire, recordFromWire, responseFromWire, } from "./ontology-wire.js";
3
- import { toCamelCase, toSnakeCase } from "./ontology-names.js";
3
+ import { toCamelCase, toReversibleCamelCase, toSnakeCase } from "./ontology-names.js";
4
4
  const ENTITY_NAME = /^[a-z][a-zA-Z0-9]*(?:_[a-z0-9]+)*$/;
5
5
  const ACTION_NAME = ENTITY_NAME;
6
+ /** The ontology service's model-level aggregate, `/api/ontology/aggregate`; never an entity. */
7
+ const MODEL_AGGREGATE = "aggregate";
6
8
  /** Names a caller may probe on any object; never an entity or an action. */
7
9
  const RESERVED = new Set([
8
10
  "then",
@@ -21,11 +23,16 @@ const RESERVED = new Set([
21
23
  * Entities and actions exist only in the app's types, so the client resolves them by name when
22
24
  * they are used: `ontology.lead.actions.changeStatus(…)` posts to `lead/actions/change_status`.
23
25
  */
24
- export function createOntologyClient(transport, aggregateTransport) {
26
+ export function createOntologyClient(transport) {
25
27
  const entities = new Map();
26
28
  const catalogReader = createCatalogReader(transport);
29
+ // Every call, aggregates included, carries the user's session: the service checks permissions.
30
+ const aggregate = (request, options = {}) => send(catalogReader.transport, "POST", `/${MODEL_AGGREGATE}`, aggregateToWire(request), options.signal).then(modelAggregateResultFromWire);
27
31
  return new Proxy(Object.create(null), {
28
32
  get(_target, property) {
33
+ if (property === MODEL_AGGREGATE) {
34
+ return aggregate;
35
+ }
29
36
  if (typeof property !== "string" || RESERVED.has(property) || !ENTITY_NAME.test(property)) {
30
37
  return undefined;
31
38
  }
@@ -33,16 +40,8 @@ export function createOntologyClient(transport, aggregateTransport) {
33
40
  if (client === undefined) {
34
41
  client = {
35
42
  ...entityClient(toSnakeCase(property), catalogReader.transport, catalogReader.getCatalog),
36
- aggregate: (request, options = {}) => {
37
- if (!aggregateTransport) {
38
- throw new TypeError("An aggregate transport is required.");
39
- }
40
- return send(aggregateTransport, "POST", `/${toSnakeCase(property)}/aggregate`, aggregateToWire(request), options.signal).then((result) => ({
41
- ...result,
42
- fields: result.fields.map((field) => ({ ...field, name: toCamelCase(field.name) })),
43
- rows: result.rows.map((row) => Object.fromEntries(Object.entries(row).map(([name, value]) => [toCamelCase(name), value]))),
44
- }));
45
- },
43
+ // Names are relative to the entity and convert as record fields do.
44
+ aggregate: (request, options = {}) => send(catalogReader.transport, "POST", `/${toSnakeCase(property)}/aggregate`, aggregateToWire(request), options.signal).then((result) => aggregateResultFromWire(result, toCamelCase)),
46
45
  };
47
46
  entities.set(property, client);
48
47
  }
@@ -212,11 +211,16 @@ export function findParameters(query) {
212
211
  }
213
212
  return search;
214
213
  }
215
- /** The platform aggregate API keeps its existing protocol keys; ontology identifiers are canonical. */
214
+ /**
215
+ * Aggregate requests keep their camelCase protocol keys and filter values; the names in them convert
216
+ * to snake_case one segment at a time.
217
+ */
216
218
  function aggregateToWire(request) {
217
219
  return {
218
220
  ...request,
219
- dimensions: request.dimensions.map(toSnakeCase),
221
+ ...(request.dimensions === undefined
222
+ ? {}
223
+ : { dimensions: request.dimensions.map(toSnakeCase) }),
220
224
  measures: request.measures.map(toSnakeCase),
221
225
  ...(request.granularityOf === undefined
222
226
  ? {}
@@ -240,6 +244,39 @@ function aggregateToWire(request) {
240
244
  }),
241
245
  };
242
246
  }
247
+ /**
248
+ * Field names, the filters each measure applied and row keys come back camelCase per segment;
249
+ * `execution` is passed on as sent.
250
+ */
251
+ function aggregateResultFromWire(result, decodeName) {
252
+ return {
253
+ ...result,
254
+ fields: result.fields.map((field) => ({
255
+ ...field,
256
+ name: decodeName(field.name),
257
+ ...(field.filters === undefined ? {} : { filters: field.filters.map(decodeName) }),
258
+ })),
259
+ rows: result.rows.map((row) => Object.fromEntries(Object.entries(row).map(([name, value]) => [decodeName(name), value]))),
260
+ };
261
+ }
262
+ /** Model-level names keep an `_` before a digit, and the entities in `sources` convert too. */
263
+ function modelAggregateResultFromWire(wire) {
264
+ const result = aggregateResultFromWire(wire, toReversibleCamelCase);
265
+ const sources = result.execution.sources;
266
+ if (sources === undefined) {
267
+ return result;
268
+ }
269
+ return {
270
+ ...result,
271
+ execution: {
272
+ ...result.execution,
273
+ sources: sources.map((source) => ({
274
+ ...source,
275
+ entity: toReversibleCamelCase(source.entity),
276
+ })),
277
+ },
278
+ };
279
+ }
243
280
  async function send(transport, method, path, body, signal) {
244
281
  const headers = new Headers({ accept: "application/json" });
245
282
  if (body !== undefined) {
@@ -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.9.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",