@segmentstream-cloud/app
v0.30.1
Published
Sign-in, users, permissions, the ontology client and warehouse data access for SegmentStream workspace apps built with Next.js.
Maintainers
Readme
@segmentstream-cloud/app
The ontology client uses camelCase entities, records and actions, including
createdAt, assignedOwnerId, and actions.changeStatus(...). It translates
queries and declared action inputs to snake_case REST and decodes records back to
camelCase. The catalog is read lazily and cached by ontology hash so nested
relationships and select identifiers convert while JSON property contents,
provider payloads and opaque action results remain unchanged.
Sign-in, users, permissions, the ontology client and warehouse data access for
SegmentStream workspace apps built with Next.js (App Router, version 16 or later) and
React 19. The app's users sign in with an email code, or a sign-in link the first time,
through Neon Auth (Managed
Better Auth) on the workspace's Neon project; the workspace's ontology decides who may
use the app and what they may do. The SDK wraps @neondatabase/auth, so the app never
configures Neon Auth itself.
Install
npm install @segmentstream-cloud/appDeploying workspace source
Main pushes deploy the workspace by default. To accumulate source updates for an explicit
release, commit .segmentstream/deployment.json with
{ "schemaVersion": 1, "trigger": "manual" }. Pushes still store and index source;
segmentstream deploy explicitly deploys current main with the existing authorization and
idempotency checks. Removing the file or selecting "push" restores automatic deployment.
This repository setting is read by Cloud, not by the app SDK, and adds no scheduler.
For an existing hourly release process, the optional productionWindow setting
in the same file restricts explicit production admission to configured UTC minutes.
Outside that window, create and retry return deployment_window_closed with the
next opening time; no attempt starts. This adds no scheduler or preview restriction.
Set up
src/segmentstream.ts configures the app once. Pass the type of the app's
ontology to type segmentstream.ontology; the ontology is imported as a type only:
import { createSegmentStreamApp } from "@segmentstream-cloud/app";
import type ontology from "../../ontology";
import link from "../segmentstream.json"; // { "workspaceId": "…", "organizationId": "…" }
export const segmentstream = createSegmentStreamApp<typeof ontology>(link);src/proxy.ts keeps every page and API route signed in:
import { segmentstream } from "./segmentstream";
export const proxy = segmentstream.proxy({
// Optional: paths anyone may open. `/webhooks` also covers `/webhooks/stripe`.
publicPaths: ["/webhooks"],
});
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};Static assets, /api/auth, /sign-in and /api/health are always open.
src/app/api/auth/[...path]/route.ts serves the Neon Auth API that sign-in and
sessions use:
import { segmentstream } from "@/segmentstream";
export const { GET, POST, PUT, DELETE, PATCH } = segmentstream.authHandlers();The route completes emailed sign-in links (/api/auth/magic-link/verify) itself: it
verifies the token through Neon Auth without a callback, sets the session cookies on
the app's domain and redirects to the link's callbackURL path. An expired, used or
unknown link redirects to /sign-in?error=sign_in_link_invalid, where <SignIn /> (or
useSignIn().linkInvalid) offers a new one.
src/app/sign-in/page.tsx renders the sign-in form, and the root layout imports
its stylesheet once. Apps with their own design system render their own form on
useSignIn() instead and skip the stylesheet:
import { SignIn } from "@segmentstream-cloud/app/ui";
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
export default async function SignInPage({ searchParams }: { searchParams: SearchParams }) {
const { returnTo, error } = await searchParams;
return (
<SignIn
returnTo={typeof returnTo === "string" ? returnTo : undefined}
error={typeof error === "string" ? error : undefined}
/>
);
}// src/app/layout.tsx
import "@segmentstream-cloud/app/ui/styles.css";Read the signed-in user
import { segmentstream } from "@/segmentstream";
export default async function Page() {
const user = await segmentstream.requireUser();
return (
<p>
{user.name ?? user.email}: {user.roles.join(", ")}
</p>
);
}requireUser()returns{ id, email?, name?, roles, permissions, scopes?, kind?, active?, authUserId?, organizationId, workspaceId }.idis the person's ontologyuserrecord. Roles and permissions come from the ontology'sGET /api/ontology/me, cached per session for a minute;requireUser({ fresh: true })asks again and caches the new answer. Signed out, it redirects to/sign-in. For a person the app has not invited (403 not_invited), it redirects to/sign-in?error=not_invited, where<SignIn />asks them to request an invitation or use another email.getUser()returns the same, ornullwhen signed out, and takes{ fresh }; it throwsSegmentStreamApiErrorwithcode: "not_invited"for people without an invitation.can("lead:changeStatus")istruewhen the user's permissions cover the permission. Roles may hold wildcards:lead:*,*:read,*:*.isAllowedandgrantsapply the same rule to any list of permissions.scopes, present only when a held permission covers some records only (a scoped permission, ontology 0.40.0 and later), lists them as{ permission, where }.permissionis spelled as inpermissions, which still holds it, socan()istrue;whereis the condition in the wire form of a query, the user as{ "$me": [] }and a path from them as{ "$me": ["staff_member", "division"] }. It is informational, for what the app shows: the ontology service enforces the scope on every read and write.- The cached roles decide what the app shows. Every
segmentstream.ontologyrequest is checked by the ontology service with the member's current roles, so it never needsfresh. Passcan(permission, { fresh: true })where code outside the ontology, such as a warehouse query or a job start, is gated on a permission that a role change must take away at once. signOut()ends the session, clears its cookies and redirects to/sign-in(or{ redirectTo }). Call it from a Server Function or Route Handler.
Use them in Server Components, Route Handlers and Server Functions. Always check the user in Server Functions and Route Handlers too; do not rely on the proxy alone.
Client components
@segmentstream-cloud/app/ui holds React client components with plain, neutral CSS
(no Tailwind). Every class starts with ss-; the --ss-* custom properties on
:root set colours, radius and font.
| Export | Does |
| -------------------------------------- | ------------------------------------------------------------------------------- |
| <SignIn /> | Email, then a six-digit code or a sign-in link; resend after 30 seconds, errors |
| useSignIn() | The same sign-in without markup, for the app's own form |
| <SignedIn>, <SignedOut> | Render children by session state |
| <Can permission="lead:changeStatus"> | Renders children when the user holds the permission; fallback otherwise |
| <UserMenu /> | Name, email, roles and sign-out |
| <AgentConnect /> | The agent connect page: the pairing, an Approve button and the result |
| useUser() | { status, user, error } from /api/ontology/me, loaded once per session |
| usePermissions() | { isLoading, roles, permissions, can(permission) } |
| useSession(), signOut() | The selected browser session; sign-out from the browser |
For an OIDC or channel session, signOut() revokes the selected grant before clearing browser
state and navigating. It rejects on revocation failure, keeping the session available for retry.
<Can> only hides controls: the ontology service checks every read and action. A
Server Component may wrap server-rendered children in <Can>:
<Can permission="lead:changeStatus" fallback={lead.status}>
<ChangeStatusForm lead={lead.id} />
</Can>Your own sign-in form
useSignIn({ returnTo, error }) holds everything <SignIn /> does, without markup: the
sign-in check, the steps, the 30-second resend hold, the six-digit check, error sentences
and their codes, the return to returnTo after sign-in and the not_invited and
sign_in_link_invalid states. Both options default to the page's query parameters.
submitEmail() first asks the ontology's POST /api/ontology/sign-in/check whether the
address may sign in. A member who has signed in with Neon Auth before gets a six-digit
code (step: "code"). A first sign-in gets a sign-in link instead (step: "link"),
because Neon Auth sends codes only to existing accounts and the link creates the
account. An address the ontology refuses gets errorCode: "not_invited", and nothing
is sent. Without an ontology that answers the check (none, or one deployed with
@segmentstream-cloud/ontology before 0.4), every address gets a code. The workspace starter renders it with
Tailwind CSS and shadcn/ui in src/components/sign-in-form.tsx; <SignIn /> stays for
apps without Tailwind.
"use client";
import { useSignIn } from "@segmentstream-cloud/app/ui";
export function SignInForm() {
const signIn = useSignIn();
if (signIn.step === "email") {
return (
<form
noValidate
onSubmit={(event) => {
event.preventDefault();
void signIn.submitEmail();
}}
>
<input
type="email"
value={signIn.email}
onChange={(event) => signIn.setEmail(event.target.value)}
/>
{signIn.error !== null && <p role="alert">{signIn.error}</p>}
<button disabled={signIn.pending !== null}>Continue</button>
</form>
);
}
if (signIn.step === "link") {
return (
<p>
We sent a sign-in link to {signIn.email}. Open it to sign in.
<button type="button" onClick={() => void signIn.resend()} disabled={signIn.resendIn > 0}>
Resend link
</button>
</p>
);
}
return (
<form
noValidate
onSubmit={(event) => {
event.preventDefault();
void signIn.submitCode();
}}
>
<input
inputMode="numeric"
autoComplete="one-time-code"
value={signIn.code}
onChange={(event) => signIn.setCode(event.target.value)}
/>
{signIn.error !== null && <p role="alert">{signIn.error}</p>}
<button disabled={signIn.pending !== null}>Sign in</button>
<button type="button" onClick={() => void signIn.resend()} disabled={signIn.resendIn > 0}>
Resend code
</button>
</form>
);
}| Field | Means |
| -------------------------------------- | ------------------------------------------------------------------------------------- |
| step | email, then code once a code is sent, or link once a sign-in link is sent |
| email, setEmail, code, setCode | The field values; submitEmail() trims the address |
| pending | send, verify, resend or sign-out while a call runs; disable the form |
| error, notice | A sentence for the person, and a confirmation such as a resent code |
| errorCode, noticeCode | Why the call failed (not_invited, code_invalid, …) and what was resent, to reword |
| resendIn | Seconds until resend() may be offered; 0 when it may |
| submitEmail(), submitCode(value?) | Check, then send a code or link; verify value or code. A wrong code clears code |
| resend(), changeEmail() | Another code, or link, to the same address; back to the email step |
| signedIn, destination | A session already exists: offer a link to destination |
| notInvited, sessionEmail | The person is signed in as sessionEmail without an invitation |
| switchAccount() | Signs out and returns to the email step |
| linkInvalid | An emailed sign-in link was expired or used: say so on the email step |
The calls take values, not form events: call submitEmail() and submitCode() from the
form's submit handler after event.preventDefault(). A second submitCode() while one
runs is ignored, so the last digit and the Enter key may both submit.
Telegram Mini App sign-in
The Mini App is the same app on any page. Enable it explicitly in both SDKs:
// App SDK configuration
createSegmentStreamApp({ ...link, channels: { telegram: true } });
// Ontology service configuration (the connection used by chat.telegram)
createOntologyService(ontology, {
...config,
channels: { telegram: { connection: "chat_bot", miniApp: {} } },
});Without these opt-ins, exchange and Telegram framing stay disabled. Providers default to invited:
paired members can sign in. Workspace apps may explicitly open signIn.channels.telegram for new
accounts and choose their restricted member grants, onNewUser action, notice/reply content and
lower limits. The exchange uses A1's shared admission, limits and onboarding; browser values cannot
change them. Members without email are managed by member ID.
Mount <TelegramSignIn /> in a client layout on any page, or call useTelegramSignIn() and render
its {status, error} codes with app-owned copy. <SignIn /> automatically exchanges when the app
opts in and Telegram launch data is present; renderTelegram optionally renders its status. No
dedicated Mini App route is provided. In a top-level WebView, an unauthenticated deep link redirects
to /sign-in?returnTo=…; HTTP redirects inherit Telegram's URL fragment, and the SDK reads
tgWebAppData there. Raw assertions never enter redirect queries, logs or persistent storage.
For Telegram Web, an unauthenticated iframe navigation instead receives an inert exchange shell
at the original protected URL. It renders no workspace content and makes one exchange, then checks
that the browser retained the session cookie before reloading the deep link. This requires the app's
explicit Telegram opt-in and is limited to iframe GET navigations. Decision pages remain unframeable.
Missing launch data stays idle; a refusal or failed discovery stops without reloading or falling back
to another provider. The shell has no UI copy; its data-channel-sign-in and
data-channel-sign-in-error attributes expose status and machine codes for inspection.
If the same launch returns to the shell after its one allowed reload, it stops with launch_loop;
reopen the Mini App with fresh launch data to retry.
Safari and other browsers without usable CHIPS cannot persist an iframe session; the regular
sign-in redirect remains DENY inside an iframe, so use a top-level Mini App launch.
Browsers without Fetch Metadata headers fall back to that DENY sign-in redirect.
Apps using Telegram's Web App script can also supply fresh data from Telegram.WebApp.initData:
"use client";
import { TelegramSignIn } from "@segmentstream-cloud/app/ui";
export function ChannelSession() {
return <TelegramSignIn />;
}The hook posts each fresh assertion once. A single timer renews at the discovered session's
expires_at, without polling. Supply initData={() => freshRawInitData} for a custom integration;
if Telegram still supplies an expired assertion, reopen the Mini App. The app owns refusal text.
A valid channel session takes precedence over regular OIDC and Neon sessions on server, proxy and
browser. Stale channel cookies fall through to regular sign-in. requireUser, ontology transport,
useSession, useUser, permissions and logout use that selection. JavaScript receives session
metadata, never the opaque token. getAccessToken() remains the legacy SegmentStream credential
API, and legacy SegmentStream-mode can() keeps its previous behavior. Routes live outside
/api/auth/*.
Session discovery, configuration and proxy verification/refresh outages remain unavailable; they never
select another provider's user or start an assertion exchange. Retry session discovery or reload
after recovery. Malformed channel/OIDC identity responses also stop authentication; OIDC discovery
retains upstream failures instead of reporting a signed-out session. Only confirmed absence or
refusal is treated as signed out.
Browser discovery probes Neon only when the app configures it. Telegram sign-in also works in
legacy SegmentStream apps without Neon or OIDC. A separate SEGMENTSTREAM_ONTOLOGY_URL uses its
HTTP(S) origin consistently for channel discovery and protected-page validation.
Public proxy paths and OAuth metadata discovery bypass cookie verification in every authentication
mode. Unsupported metadata returns 404 without starting sign-in.
Sessions last one hour absolutely, without refresh tokens. HTTPS cookies are HttpOnly, Secure, SameSite=None and Partitioned. Logout revokes the grant and clears the cookie. Renewal, unpairing and pairing displacement revoke only that identity's grants; blocking/deactivation revoke all of that member's sessions. Writes, exchange and logout require the app's Origin. Framing by Telegram Web is enabled only with app opt-in; sign-in, consent and pairing pages always remain DENY.
Before release, manually check Telegram iOS, Android, Desktop and Web: deep-link launch/fragment retention,
exchange, reload, expiry/fresh data, partitioned-cookie persistence, logout/reopen, mixed sessions
and revoked/blocked/deactivated members. These checks need a deployed HTTPS app and are separate
from local fixtures. CI runs the HTTPS iframe suite when Chrome is installed; opt in locally with
CHROME_BIN=/usr/bin/google-chrome pnpm --filter @segmentstream-cloud/app test --maxWorkers=1 tests/telegram-launch.browser.test.ts.
It uses Chrome and OpenSSL installed on the host and a temporary self-signed certificate.
The ontology
segmentstream.ontology.<entity> calls the app's ontology service at /api/ontology
with the signed-in user's Neon Auth JWT, so the service applies their permissions.
Records, action inputs and action results are typed from the ontology:
const { items, nextCursor } = await segmentstream.ontology.lead.find({
where: { status: { in: ["new", "contacted"] } },
orderBy: [{ field: "createdAt", direction: "desc" }],
include: ["owner"],
limit: 20,
});
const lead = await segmentstream.ontology.lead.get(id);
const history = await segmentstream.ontology.lead.history(id, { limit: 50 });
const page = await segmentstream.ontology.lead.query({ where: { email: { startsWith: "ada" } } });
const input = { lead: id, status: "contacted" } as const;
const check = await segmentstream.ontology.lead.actions.changeStatus(input, {
validateOnly: true,
});
const { result, validation, edits } = await segmentstream.ontology.lead.actions.changeStatus(input);findsends the query as URL parameters,queryas a JSON body; both return{ items, nextCursor }. PassnextCursorasafterfor the next page.includeadds related records under their relationship names: list the names,["owner", "tags"], or pass{ owner: true, tags: true }.select: ["email", "status"]returns only those base fields, plusidand any requestedincluderelationships. An empty list returns ids only. Selected pages carryprojected: true; the typed client marks unselected fields optional. Filtering and cursor ordering may use unselected fields. Omitselectto receive complete records.getand action reads always return complete records.actions.<name>(input, { validateOnly })posts{ input, options }and returns{ result, validation, edits }. Generated actions are there too (actions.create,update,delete), andaction(name, input)runs any action by name, such asupsert_by_email.segmentstream.ontology.userreads members and services. Each record hasroles, the role names assigned to it withoutmember, spelled asrequireUser().rolesspells them; filter on them withwhere: { roles: { has: "sales" } }or{ roles: { hasAny: ["sales", "support"] } }. Its generated actions change them:actions.invite({ email, name?, roles? }),grantRoles({ id, roles }),revokeRoles({ id, roles })orrevokeRoles({ id, all: true }),deactivate({ id })andreactivate({ id }). Each needs its explicituser:<verb>permission, which*:*does not cover, writes history and returns the updated record. ListUser(orUser.actions(…)) in the ontology'sentitiesto typesegmentstream.ontology.user.- Failures throw
SegmentStreamApiErrorwith the service'scode(forbidden,not_found,invalid_input,action_rejected,conflict,not_invited),status,detail,requestIdand, for invalid input,issues. - The types are read from the ontology SDK's
Ontology, entity and action types structurally, so the app needs neither the ontology SDK nor Effect at runtime. Without a type argument, any entity name is accepted and records are untyped.
Upload files
In a Neon Auth app, import the browser client from @segmentstream-cloud/app/storage
and send its metadata requests to the app's own ontology path:
import { createBlobStorageClient } from "@segmentstream-cloud/app/storage";
const files = createBlobStorageClient({
fetch: (path, init) => fetch(`/api/ontology${path}`, { ...init, credentials: "same-origin" }),
});
// In the file picker: keep this UUID for retries of this file's upload.
const uploadKey = crypto.randomUUID();
const uploaded = await files.uploadForAction(
{ entity: "document", property: "content", action: "create" },
selectedFile,
{ idempotencyKey: uploadKey },
);The browser attaches the member's HttpOnly Neon session cookie automatically. The
ontology checks the target action's permission before issuing a transfer grant;
missing permission answers 403. File bytes go directly to storage without member
credentials. Import the /storage entry point in browser code; the package root
loads the server-side app integration. No token handling or segmentstream.fetch
call is needed.
Send uploaded.ticket and uploaded.options as JSON to a workspace Server Function
or Route Handler. As the same signed-in member, server code calls:
await segmentstream.ontology.document.actions.create(
{ title: "Contract", content: { uploadId: uploaded.ticket.uploadId } },
uploaded.options,
);Upload completion alone does not change a record. Keep the upload result and retry
the action with the same input and options if its response is lost; do not restart
an upload after its ticket has been consumed. The helper supplies createIntent
for a new record; use recordId in the target when changing an existing record.
The deployed blob property, target action and workspace quota must permit the file.
See the complete browser upload and Server Function example
for authentication, retries, cancellation, and local development.
Download files
Server Components and Route Handlers request blob download grants through the same member-authenticated client, including under Neon Auth:
const grant = await segmentstream.ontology.document.files.download(id, "content", {
inline: false,
});
// grant: { url, method: "GET", headers, expiresAt }The ontology checks current document:read permission and record scopes. The
property name is typed to blob properties; options also accept revisionId and
signal. Missing permission throws SegmentStreamApiError with status 403;
an inaccessible record or revision answers 404. The helper never reads file bytes.
Use a Route Handler to obtain a fresh grant and redirect the browser to its signed
GET URL with Cache-Control: private, no-store. The browser downloads directly
from storage, including files over 2 MiB. Keep the app route as the durable link,
since grants expire. See the complete
Route Handler example.
Normal app downloads need no custom transport or token handling, and do not use
segmentstream.fetch or the workspace machine credential. Browser uploads use
the separate storage client.
Agent connect page
The workspace agent answers a chat account it does not know, such as a Telegram user,
with a link to /agent/connect?code=<code>. A signed-in member approves there, and the
ontology links the account to them: the agent then acts as that member, with their
current roles, for that account's messages. The proxy signs the page in like any other
and forbids other sites to frame it.
// src/app/agent/connect/page.tsx, without Tailwind
import { SegmentStreamApiError, type AgentPairing } from "@segmentstream-cloud/app";
import { AgentConnect } from "@segmentstream-cloud/app/ui";
import { segmentstream } from "@/segmentstream";
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
export default async function ConnectPage({ searchParams }: { searchParams: SearchParams }) {
const user = await segmentstream.requireUser();
const { code, connected } = await searchParams;
let pairing: AgentPairing | null = null;
if (typeof code === "string" && typeof connected !== "string") {
try {
pairing = await segmentstream.agent.pairing(code);
} catch (error) {
// A used or expired code shows "This link has expired"; anything else is an error.
if (
!(error instanceof SegmentStreamApiError) ||
!["not_found", "expired"].includes(error.code)
) {
throw error;
}
}
}
return (
<AgentConnect
code={typeof code === "string" ? code : undefined}
pairing={pairing}
connected={typeof connected === "string" ? connected : undefined}
member={user.email ?? undefined}
/>
);
}// src/app/api/agent/connect/route.ts
import { segmentstream } from "@/segmentstream";
export const { POST } = segmentstream.agent.connectHandler();segmentstream.agent holds:
| Call | Does |
| ---------------------- | -------------------------------------------------------------------------------------- |
| pairing(code) | { provider, display, expiresAt }; throws not_found or expired |
| approvePairing(code) | Links the account to the signed-in member; returns the AgentIdentity |
| identities() | The member's own: { id, provider, externalId, display, createdAt, revokedAt } |
| revokeIdentity(id) | Revokes one of them at once |
| connectHandler() | { POST } for the page's form: same-origin only; 303 on success; throws on conflict |
The handler sends the browser back to /agent/connect?connected=<provider>, or to
/agent/connect?expired=1 when the code was used or is older than ten minutes. An account already
connected to another member throws SegmentStreamApiError with status 409 and
code === "identity_in_use" through the app's existing error mechanism. The app owns conflict copy.
Same-member pairing is idempotent, and replacing your own previous account on a provider is supported. Other
refusals, such as forbidden for a member the ontology does not admit, throw
SegmentStreamApiError. All calls are server-only and run as the signed-in member;
the ontology decides every one. The workspace starter renders the page with Tailwind CSS
and shadcn/ui; apps made from an earlier starter add src/app/agent/connect/page.tsx
and src/app/api/agent/connect/route.ts from it.
Query the warehouse
import { sql } from "@segmentstream-cloud/app";
import { segmentstream } from "@/segmentstream";
type Revenue = { order_date: string; total_revenue: number };
export default async function Page() {
await segmentstream.requireUser();
const { rows } = await segmentstream.warehouse.query<Revenue>(
`SELECT order_date, total_revenue
FROM gold.revenue_summary
WHERE order_date >= @from
ORDER BY order_date`,
{ params: { from: sql.date("2026-09-01") } },
);
return <pre>{JSON.stringify(rows, null, 2)}</pre>;
}- Warehouse calls authenticate with the workspace app credential,
SEGMENTSTREAM_APP_CREDENTIAL, which SegmentStream sets on deploy and the SDK reads on every call. Without it they use the legacy sign-in's session token. The credential belongs to the app, so check the user before querying for them. - Queries are read-only GoogleSQL over the workspace warehouse.
dataset.tableresolves to the workspace's project. Anything other than a singleSELECTis rejected. - Pass values as named
@parametersinparams; never build SQL from user input. Strings, booleans,bigint, safe integers (INT64), other numbers (FLOAT64),Date(TIMESTAMP) and arrays of these are typed automatically. Usesql.date(),sql.datetime(),sql.timestamp(),sql.numeric(),sql.bignumeric(),sql.int64()andsql.array(type, values)for anything else. query()waits for running queries (timeoutMs, default 60 s) and follows result pages up tomaxRows(default 10,000);truncatedreports whether more rows exist.maximumBytesBilledlowers the per-query byte ceiling;warehouse.dryRun()returns the estimated bytes and result columns without running the query.- Long queries keep running in the warehouse for up to an hour after
query()stops waiting. It then throwsquery_timeoutwith aqueryId. Resume the query from a later request, for example a Route Handler your page polls, withwarehouse.result(queryId). Don't hold one request open for minutes. - Values arrive as JSON: safe integers as numbers, larger integers and decimals as strings, timestamps as ISO strings, records and arrays as JSON strings.
One authorization model covers the ontology: every segmentstream.ontology call, aggregates
included, runs as the signed-in user through the ontology service, which checks their
permissions. segmentstream.ontology.<entity>.aggregate(request) posts to
/api/ontology/<entity>/aggregate and needs read permission on the entities it reaches.
segmentstream.warehouse uses the workspace app credential and reaches the whole workspace
warehouse. The job helpers below use it for explicitly opted-in jobs. segmentstream.fetch(path) still requires the legacy
platform session. Aggregates through the service need the workspace's ontology deployed with
@segmentstream-cloud/ontology 0.13.0 or later.
Since 0.10.0, dimensions, filters and orderBy accept relationship paths, typed from the
ontology's relationships:
const { rows, execution } = await segmentstream.ontology.order.aggregate({
dimensions: ["customer.segment"],
measures: ["orders"],
dateRange: { from: "2026-09-01", to: "2026-09-30" },
});- A path is
<relationship>.<property>, through one or two to-one relationships. Paths into a stored target includeid; json and blob properties are left out. A thunk target types its paths as`customer.${string}`. Each segment converts to snake_case on the wire, and rows come back keyed by the camelCase path. These names are relative to the entity and convert as record fields do:line_2reads asline2. execution.warningsreports keys that matched more than one related row; totals are unaffected. Warnings are absent when the query produced none. Stored replicas are eventually consistent; current responses do not reportexecution.sourcesor a destination freshness watermark.- 0.10.0 requires the platform release that joins stored targets through replication.
- See ontology aggregates.
Since 0.10.0, segmentstream.ontology.aggregate(request) asks for numbers about the whole
model: measures of several entities, warehouse-backed or stored, grouped by properties they all
reach. Look at or change records through find and actions; count, sum or compare through
aggregate.
const { fields, rows, execution } = await segmentstream.ontology.aggregate({
measures: ["acmeSales.revenue", "acmeStock.onHand"],
dimensions: ["acmeProduct.assortmentStatus"],
filters: [
{
dimension: "acmeSales.orderDate",
operator: "between",
values: ["2026-09-01", "2026-09-30"],
},
{ dimension: "acmeSales.currency", operator: "equals", value: "RUB" },
],
orderBy: [{ measure: "acmeSales.revenue", direction: "desc" }],
});- Names are
entity.measureandentity.property, each segment in camelCase, typed from the ontology. An_before a digit stays, so an entity's declared name converts back:sales_2025.revenue. This differs from the per-entity call and records, which drop it. Fields and rows are keyed by these names. - When an entity reaches a property's entity in more than one way, write a route from it:
acmeSales.returnedProduct.brand. Its column is still keyed by the property's own name,acmeProduct.brand. - It posts to the ontology service's
/api/ontology/aggregateas the signed-in user, who needs read permission on every entity the request reaches. Previews and local development answeraggregate_unavailable. - Date bounds are
betweenfilters, inclusive, orinPeriodfilters. Each partitioned entity needs one on its own partition property. Currency measures need their entity filtered to one currency, the same for every entity. - A measure field's
filterslists the filters applied to it, andexecution.warningsnames each filter that did not reach a measure's entity.additive: falsemarks a measure whose rows don't add up to a total. - Failures throw
SegmentStreamApiErrorwith the service'scode:invalid_request,invalid_aggregate_query,forbidden,rate_limited(30 aggregates a minute per user),aggregate_unavailable,aggregate_upstream_unavailable,ontology_version_mismatchoraggregate_scope_mismatch. See ontology aggregates.
Since 0.13.0, both aggregates take more conditions, buckets and a timezone:
const { rows, execution } = await segmentstream.ontology.aggregate({
measures: ["acmeSales.wonOrders"],
dimensions: ["acmeSales.orderDate"],
granularity: "week",
granularityOf: "acmeSales.orderDate",
filters: [
{ dimension: "acmeSales.orderDate", operator: "inPeriod", value: "last 4 weeks" },
{ dimension: "acmeProduct.brand", operator: "notIn", values: ["Unknown", "Test"] },
],
timezone: "Europe/Moscow",
});- Filter operators are
equals,in,notEquals,notIn,isNull,isNotNull,gt,gte,lt,lte,contains,startsWithandendsWith; the model-level call addsbetweenandinPeriod.notEqualsandnotInalso match nulls and rows whose relationship found no target. Text operators are case-sensitive and take no wildcards. inPeriodtakes a period relative to today, typed asRelativePeriod:today,yesterday,this month,last quarter,this year to date,last 7 days,last 4 weeks.execution.periodsgives the dates each one resolved to and the timezone that decided today. OnlybetweenandinPeriodbound a partitioned entity.granularityishour,day,week,month,quarteroryear;hourneeds a timestamp dimension.timezone, an IANA name orUTC, replaces the entities' timezones for timestamp buckets, date bounds on timestamps and the today ofinPeriod.- A balance measure's field carries
balanceOver, the date dimension its rows snapshot. Group by that dimension or filter it to one day.balanceOverand each period'sdimensioncome back camelCase per segment, like field names. - The per-entity call takes the new operators, granularities and
timezone, but neitherbetweennorinPeriod:dateRangestays its partition bound. - These requests need app SDK 0.13.0 and the workspace's ontology deployed with
@segmentstream-cloud/ontology0.17.0 or later, whose service accepts them; earlier versions refuse them. See Upgrading to 0.13.
Since 0.14.0, the model-level call also takes the measures of the model, which the ontology
declares in defineOntology({ measures }) over measures of several entities, by their bare
names beside entity.measure names:
const { fields, rows } = await segmentstream.ontology.aggregate({
measures: ["planAttainment", "acmeSales.ordered"],
dimensions: ["acmeSales.orderDate"],
granularity: "month",
granularityOf: "acmeSales.orderDate",
filters: [{ dimension: "acmeSales.orderDate", operator: "inPeriod", value: "this year to date" }],
orderBy: [{ measure: "planAttainment", direction: "desc" }],
});
// fields[1]: { name: "planAttainment", label: "Plan attainment", unit: "ratio", additive: false,
// filters: [...], inputs: ["acmeSales.ordered", "acmePlans.plannedUnits"] }- A measure of the model is requested by the camelCase name the ontology declares, typed from
typeof ontology. The platform names it in snake_case,plan_attainment; its field and row key are the camelCase form,planAttainment, as for entity measures. - Its field carries
inputs, the entity measures it reads, asentity.measurein camelCase per segment, like other names. They are computed whether or not you request them; request one to get its column too. - Its
filterslists the filters that applied to every input. A filter on a property the measure aligns applies to each entity through its own property; a filter that reaches some of its entities and not others is refused withinvalid_aggregate_query. - A group one of its entities has no rows for, or a zero denominator, gives
null. - The caller needs read permission on every entity it reads.
- These requests need the workspace's ontology deployed with
@segmentstream-cloud/ontology0.23.0 or later, which declares and serves measures of the model; earlier versions refuse them. Requests that name none work as before, and their fields carry noinputs.
Start a pipeline job
With app SDK 0.15.0 and pipeline SDK 0.1.0a65 or later, opt in a job and deploy it:
s.job("land_watch", assets=[land_checks], allow_app_runs=True)In server code, check the user's role or permissions before calling on their behalf. The
credential is the app's, so ask with { fresh: true } when a revoked role must stop the run at
once:
const user = await segmentstream.requireUser({ fresh: true });
if (!user.roles.includes("operator")) {
throw new Error("An operator role is required.");
}
const run = await segmentstream.jobs.run("land_watch");
// From a later authorized request, for example a Route Handler the page polls:
const current = await segmentstream.jobs.getRun(run.id);
const recent = await segmentstream.jobs.listRuns("land_watch");The helpers use the workspace app credential, read on every call, or fall back to the
legacy member session. Credential access defaults to denied unless the current deployed
job sets allow_app_runs=True. It stays within the workspace and permits submission and
status/history reads; run logs, activity and cancellation require platform membership.
Removing the flag revokes app access to that job's runs.
run() returns immediately with a run ID and status. It generates a UUID idempotency key
unless passed { idempotencyKey }; reuse an explicit key when retrying one button action.
Separate calls with new keys can run concurrently. Partitioned jobs accept
{ partitionRange: { startDate, endDate } }. All helpers accept an optional signal.
Poll getRun() until status is succeeded, failed or cancelled, and tolerate future
status values. Protect both submission and status routes with your app's authorization.
See app data access for a Route Handler.
How requests are handled
- Page navigations without a session are redirected to
/sign-in?returnTo=<path>and return to the page after sign-in. - API routes,
fetchcalls, non-GET requests and Next.js data requests receive401 {"error":"unauthenticated"}instead of a redirect. - Static assets,
/api/auth/*,/sign-in,/api/healthand yourpublicPathsnever require a session.
Environment
App previews are selected by the platform with SEGMENTSTREAM_DEPLOYMENT_TARGET=preview,
SEGMENTSTREAM_PREVIEW_EXPIRES_AT (an ISO timestamp), and the existing
SEGMENTSTREAM_ONTOLOGY_DEPLOYMENT_ID. Omitting the target preserves production behavior.
The preview receives its own Neon Auth URL, cookie secret, ontology service and expiring
machine credential. It cannot fall back to legacy SegmentStream sign-in or Cloud OIDC.
segmentstream.deployment is a serializable DeploymentObservation: target, optional
deploymentId, preview expiresAt, and capabilities (warehouseRead, pipelineRuns,
connections, agents, workflows). A preview enables warehouseRead and workflows in this profile;
its isolated ontology CRUD and file operations remain available under normal user permissions.
Workflows support manual starts, action/change triggers and inbound webhooks. Scheduled cron
triggers remain disabled. Trusted workspace workflow code can make external network requests
and cause real side effects; managed connections, agents and pipeline runs remain unavailable.
Pass this observation to your UI to present your own preview indicator and unavailable actions.
The SDK supplies codes and values; the workspace supplies human-language text.
Preview pipeline methods, agent operations and their Route Handlers are refused before external
requests. Every server helper, proxy and auth handler checks the deadline, including cached
sessions and clients captured before expiry. DeploymentAdmissionError exposes stable code
and status values, including preview_expired (410), preview_capability_unavailable (403)
and preview_configuration_invalid (503). These SDK observations and guards do not authorize
the machine credential: the platform independently enforces its deployment and capability scope.
| Variable | Purpose |
| -------------------------------------- | ------------------------------------------------------------------------------- |
| NEON_AUTH_BASE_URL | The workspace's Neon Auth URL. Set: sign in with Neon Auth. Unset: 0.2 sign-in. |
| SEGMENTSTREAM_APP_AUTH_COOKIE_SECRET | 32 or more characters; signs the session cache cookie. Required with Neon Auth. |
| SEGMENTSTREAM_APP_CREDENTIAL | The workspace app credential for warehouse queries and opted-in pipeline jobs. |
| SEGMENTSTREAM_ONTOLOGY_URL | Optional: the ontology service when it does not run beside the app. |
| SEGMENTSTREAM_URL | SegmentStream origin. Defaults to https://cloud.segmentstream.com. |
SegmentStream sets the first three in the app's environment on deploy.
Local development
App SDK checks and builds copy the browser-safe channel protocol from
packages/ontology-sdk/src/channel-session.ts into an ignored source module. The
packed app includes that module and does not require the ontology npm package at
runtime. Change the canonical ontology source when updating the wire contract.
Put the deployed NEON_AUTH_BASE_URL and a cookie secret in app/.env.local;
Neon Auth trusts http://localhost:3000:
NEON_AUTH_BASE_URL=https://ep-….neonauth.….aws.neon.tech/ontology/auth
SEGMENTSTREAM_APP_AUTH_COOKIE_SECRET=<openssl rand -base64 32>
SEGMENTSTREAM_ONTOLOGY_URL=http://localhost:3100With SEGMENTSTREAM_ONTOLOGY_URL, server-side ontology calls go there and the proxy
forwards the browser's /api/ontology/* requests to it. Point it at a local
npm --prefix ontology run dev or at the deployed app.
Security
- Sessions are Neon Auth's:
HttpOnly,Secure,SameSite=Laxcookies on the app's own domain, set through the app's/api/authroute. The session cache cookie is signed withSEGMENTSTREAM_APP_AUTH_COOKIE_SECRET. - Signing in proves an email address and grants nothing. The ontology links the
person to an invited member, and their roles decide what they may read and do. The
ontology service verifies the user's JWT (EdDSA, 15 minutes) and checks activity,
roles and permissions on every request.
can()and<Can>read roles cached for up to a minute and decide what the app shows; gate code outside the ontology withcan(permission, { fresh: true })when a revoked role must stop it at once. - The session JWT and the app credential stay on the server, except that the browser
components fetch the JWT from the app's own
/api/auth/tokento read/api/ontology/me. - Warehouse queries run with the workspace's own identity; the app never holds warehouse credentials.
Without Neon Auth
Without NEON_AUTH_BASE_URL, 0.3 behaves exactly as 0.2, so existing apps keep
working: members of the workspace's organization sign in with their SegmentStream
account through /api/segmentstream/[...path]/route.ts
(export const { GET, POST } = segmentstream.handlers();), the proxy redirects to
SegmentStream, and fetch acts as the member; warehouse queries prefer the workspace app credential. In that mode
roles and permissions are empty, can() is false, authHandlers() answers
404 neon_auth_not_configured, and segmentstream.ontology sends the member's app
token, which the ontology service accepts while workspaces move to Neon Auth. Keep the
legacy route until every deployment of the app has Neon Auth.
Upgrading from 0.2
Add the auth route, the sign-in page and the stylesheet import; keep the legacy route.
AppUser gained roles and permissions. Under Neon Auth, people sign in again with
an email code, and the ontology must have invited them.
Upgrading to 0.4
useSignIn().step may now be link: render it, or a form written for 0.3 shows the code
step after a link was sent. The <SignIn /> button reads "Continue". Deploy the
ontology with @segmentstream-cloud/ontology 0.4 or later to answer the sign-in check;
until then every address gets a code, as in 0.3. errorCode and noticeCode let an
app word messages in its own language without matching the English sentences.
License
Apache-2.0
Upgrading to 0.5
Replace segmentstream.semantic with typed warehouse-backed entity aggregates. See ontology aggregates. Warehouse-backed clients now also expose read-only find, query and get, with opaque record IDs; actions and stored history remain unavailable. See warehouse record reads.
Upgrading to 0.10
- Shorthand aggregates accept relationship paths. Their responses may carry
execution.warnings; historical responses also carriedexecution.sources. segmentstream.ontology.aggregate()is new, andaggregateis a reserved entity name.segmentstream.ontology.<entity>.aggregate()now runs as the signed-in user through the ontology service, like record reads, and no longer usesSEGMENTSTREAM_APP_CREDENTIAL. The user needs read permission on the entities it reaches.- Server code without a signed-in user, such as a scheduled Route Handler, can no longer
aggregate with the workspace credential. Use
segmentstream.warehousethere, or call the ontology service with an API key. - Deploy the workspace's ontology with
@segmentstream-cloud/ontology0.13.0 or later. It serves/api/ontology/<entity>/aggregateand/api/ontology/aggregate; earlier versions don't.
Upgrading to 0.13
ModelAggregateFilteris now a union: a filter withvalueorvalues, or aninPeriodfilter whosevalueis aRelativePeriod. An interface cannot extend a union, sointerface X extends ModelAggregateFilterno longer compiles; writetype X = ModelAggregateFilter & { … }instead. Readingfilter.valuesstill compiles: it is alwaysundefinedon aninPeriodfilter.- The new operators,
inPeriod, granularities andtimezoneneed the workspace's ontology deployed with@segmentstream-cloud/ontology0.17.0 or later. Requests that use none of them work as before.
Request timing
Wrap app Route Handlers to add Server-Timing: app;dur=…, x-request-id and safe
structured app.request runtime logs:
import { withRequestTiming } from "@segmentstream-cloud/app";
export const GET = withRequestTiming(async (request: Request) => {
return Response.json({ ok: true });
});The wrapper passes handler arguments through and preserves response headers and
streaming bodies. It logs duration, method, HTTP status and a generated request ID,
excluding URLs, cookies, bodies and error details. Errors propagate unchanged.
Next.js redirects and notFound() log their HTTP status and propagate unchanged;
other Next.js control-flow exceptions log status: null because Next owns their eventual response.
Those exceptions cannot receive timing headers here;
return an explicit response when the wrapper needs to decorate its headers.
Read these logs with segmentstream logs --service app (deployment permission required).
Duration ends when the handler returns response headers; it excludes later stream
work and browser rendering. It is opt-in for Route Handlers, not a page renderer
or a web vitals collector. A Next.js proxy cannot time the downstream page render.
Ontology SDK 0.31.2 adds request timing for ontology calls automatically. Timings are available in hosted logs; retained diagnostics are tracked in issue #520.
Upgrading to 0.14
segmentstream.ontology.aggregate()accepts the measures of the model by their bare names, typed from an ontology that declares them withdefineOntology({ measures }). Ontologies without them type the same names as before.AggregateFieldgainsinputs, which only fields of measures of the model carry.
Replication lifecycle and freshness
Cloud publishes a generation's views after its baseline, backfill and replay preparation finish
and Pub/Sub accepts its fixed initialization prefix. Later writes do not postpone that boundary
or make published views unavailable. Pub/Sub acceptance does not prove BigQuery visibility;
the native BigQuery subscription handles delivery and retries. Replication status reports
captured and accepted, and aggregate responses carry no destination freshness watermark.
Initial copies and replay remain unavailable until acceptance and view publication finish.
