@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 +161 -4
- package/dist/agent.d.ts +55 -0
- package/dist/agent.js +202 -0
- package/dist/app.d.ts +7 -0
- package/dist/app.js +10 -6
- package/dist/data.d.ts +52 -8
- package/dist/data.js +1 -8
- package/dist/decision-pages.d.ts +12 -0
- package/dist/decision-pages.js +31 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +3 -0
- package/dist/oauth.d.ts +2 -8
- package/dist/oauth.js +4 -29
- package/dist/ontology-names.d.ts +7 -0
- package/dist/ontology-names.js +9 -0
- package/dist/ontology-wire.d.ts +2 -0
- package/dist/ontology-wire.js +14 -3
- package/dist/ontology.d.ts +80 -12
- package/dist/ontology.js +51 -14
- package/dist/pages.d.ts +6 -0
- package/dist/pages.js +6 -0
- package/dist/ui/agent-connect.d.ts +18 -0
- package/dist/ui/agent-connect.js +23 -0
- package/dist/ui/index.d.ts +3 -0
- package/dist/ui/index.js +1 -0
- package/package.json +1 -1
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
|
|
338
|
-
|
|
339
|
-
`segmentstream.
|
|
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
|
|
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.
|
package/dist/agent.d.ts
ADDED
|
@@ -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
|
|
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) =>
|
|
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
|
|
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) =>
|
|
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
|
|
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
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
3
|
-
|
|
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 {
|
|
4
|
-
|
|
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 (!
|
|
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();
|
package/dist/ontology-names.d.ts
CHANGED
|
@@ -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;
|
package/dist/ontology-names.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/ontology-wire.d.ts
CHANGED
|
@@ -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;
|
package/dist/ontology-wire.js
CHANGED
|
@@ -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 &&
|
|
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" &&
|
|
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;
|
package/dist/ontology.d.ts
CHANGED
|
@@ -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 :
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
|
|
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
|
-
} ?
|
|
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`)
|
|
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
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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) {
|
package/dist/pages.d.ts
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 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
|
+
}
|
package/dist/ui/index.d.ts
CHANGED
|
@@ -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.
|
|
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",
|