@segmentstream-cloud/ontology 0.8.0 → 0.10.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.
Files changed (61) hide show
  1. package/README.md +7 -1
  2. package/dist/analyze.js +16 -1
  3. package/dist/blob.d.ts +39 -0
  4. package/dist/blob.js +49 -0
  5. package/dist/entity.d.ts +5 -3
  6. package/dist/index.d.ts +4 -1
  7. package/dist/index.js +2 -0
  8. package/dist/manifest.d.ts +23 -7
  9. package/dist/manifest.js +9 -1
  10. package/dist/migrate/model.d.ts +7 -2
  11. package/dist/migrate/plan.js +26 -2
  12. package/dist/migrate/storage.d.ts +2 -0
  13. package/dist/migrate/storage.js +13 -0
  14. package/dist/names.d.ts +1 -1
  15. package/dist/names.js +1 -0
  16. package/dist/property.d.ts +10 -1
  17. package/dist/property.js +12 -1
  18. package/dist/query.js +6 -1
  19. package/dist/runtime/generated-actions.js +7 -3
  20. package/dist/runtime/history.d.ts +3 -0
  21. package/dist/runtime/pipeline.d.ts +5 -1
  22. package/dist/runtime/pipeline.js +87 -3
  23. package/dist/runtime/records.js +9 -0
  24. package/dist/runtime/service-binding.d.ts +2 -1
  25. package/dist/runtime/service-binding.js +13 -2
  26. package/dist/runtime/writes.js +17 -3
  27. package/dist/service/api.js +15 -0
  28. package/dist/service/authentication.d.ts +33 -4
  29. package/dist/service/authentication.js +142 -6
  30. package/dist/service/config.d.ts +3 -0
  31. package/dist/service/config.js +18 -0
  32. package/dist/service/docs.d.ts +9 -0
  33. package/dist/service/docs.js +197 -0
  34. package/dist/service/errors.d.ts +5 -0
  35. package/dist/service/errors.js +14 -0
  36. package/dist/service/handlers.js +40 -1
  37. package/dist/service/runtime-contract.d.ts +9 -0
  38. package/dist/service/schemas.d.ts +10 -2
  39. package/dist/service/schemas.js +18 -2
  40. package/dist/service/service.js +22 -5
  41. package/dist/storage/api.d.ts +4 -0
  42. package/dist/storage/api.js +39 -0
  43. package/dist/storage/authorization.d.ts +8 -0
  44. package/dist/storage/authorization.js +30 -0
  45. package/dist/storage/maintenance-auth.d.ts +20 -0
  46. package/dist/storage/maintenance-auth.js +24 -0
  47. package/dist/storage/maintenance.d.ts +7 -0
  48. package/dist/storage/maintenance.js +50 -0
  49. package/dist/storage/neon.d.ts +3 -0
  50. package/dist/storage/neon.js +212 -0
  51. package/dist/storage/operator.d.ts +10 -0
  52. package/dist/storage/operator.js +30 -0
  53. package/dist/storage/provider.d.ts +80 -0
  54. package/dist/storage/provider.js +20 -0
  55. package/dist/storage/sessions.d.ts +87 -0
  56. package/dist/storage/sessions.js +341 -0
  57. package/dist/storage/tables.d.ts +2 -0
  58. package/dist/storage/tables.js +55 -0
  59. package/dist/storage/writes.d.ts +7 -0
  60. package/dist/storage/writes.js +88 -0
  61. package/package.json +3 -1
@@ -1,8 +1,9 @@
1
1
  import { Cause, Context, Effect, Layer, Redacted } from "effect";
2
+ import { HttpServerRequest } from "effect/unstable/http";
2
3
  import { HttpApiMiddleware, HttpApiSecurity, OpenApi } from "effect/unstable/httpapi";
3
4
  import { createRemoteJWKSet, decodeJwt, errors, jwtVerify, } from "jose";
4
5
  import { API_KEY_PREFIX } from "../runtime/principals.js";
5
- import { RequestId, internalError, notInvited, toServerResponse, unauthorized, unavailable, } from "./errors.js";
6
+ import { RequestId, crossOriginSession, internalError, notInvited, toServerResponse, unauthorized, unavailable, } from "./errors.js";
6
7
  import { OntologyBackend } from "./runtime-contract.js";
7
8
  import { ErrorSchemas } from "./schemas.js";
8
9
  const CLOCK_TOLERANCE_SECONDS = 5;
@@ -12,8 +13,8 @@ export class CurrentPrincipal extends Context.Service()("@segmentstream-cloud/on
12
13
  }
13
14
  /**
14
15
  * Authenticates every ontology route except `status`: a Neon Auth token, a SegmentStream app token
15
- * or an API key, all as `Authorization: Bearer …`. Failures answer the request directly, before its
16
- * input is decoded.
16
+ * or an API key, all as `Authorization: Bearer …`; without one, the app's Neon Auth session cookie.
17
+ * Failures answer the request directly, before its input is decoded.
17
18
  */
18
19
  export class Authentication extends HttpApiMiddleware.Service()("@segmentstream-cloud/ontology/Authentication", {
19
20
  security: {
@@ -173,6 +174,105 @@ export function tokenVerifier(config) {
173
174
  }
174
175
  return verifyByIssuer(verifiers);
175
176
  }
177
+ /**
178
+ * The session cookie the app's Neon Auth sets on the app's domain. The ontology service shares the
179
+ * domain, so a signed-in browser sends it here too: the docs page relies on it.
180
+ */
181
+ export const NEON_AUTH_SESSION_COOKIE = "__Secure-neon-auth.session_token";
182
+ /** Session tokens kept at once; past it the oldest go first. */
183
+ const SESSION_CACHE_SIZE = 1_000;
184
+ /** A session's token is fetched again this long before it expires. */
185
+ const SESSION_TOKEN_MARGIN_MS = 60_000;
186
+ const SESSION_EXCHANGE_TIMEOUT_MS = 5_000;
187
+ /**
188
+ * Exchanges a session cookie for its token at Neon Auth's `GET <base URL>/token`, as the app's
189
+ * `@neondatabase/auth` does, and reuses the token until shortly before it expires. The token is then
190
+ * verified like any bearer token, so the exchange never vouches for a session on its own.
191
+ */
192
+ export function neonAuthSessionExchange(config, options = {}) {
193
+ const fetchToken = options.fetch ?? fetch;
194
+ const now = options.now ?? Date.now;
195
+ const endpoint = new URL("token", config.baseUrl.endsWith("/") ? config.baseUrl : `${config.baseUrl}/`);
196
+ const cache = new Map();
197
+ return async function exchangeSession(session, origin) {
198
+ const value = Redacted.value(session);
199
+ const cached = cache.get(value);
200
+ if (cached !== undefined && cached.refreshAt > now()) {
201
+ return { status: "token", token: cached.token };
202
+ }
203
+ cache.delete(value);
204
+ let response;
205
+ try {
206
+ response = await fetchToken(endpoint, {
207
+ headers: { cookie: `${NEON_AUTH_SESSION_COOKIE}=${value}`, origin },
208
+ redirect: "error",
209
+ signal: AbortSignal.timeout(SESSION_EXCHANGE_TIMEOUT_MS),
210
+ });
211
+ }
212
+ catch {
213
+ return { status: "unavailable" };
214
+ }
215
+ if (response.status === 401 || response.status === 403) {
216
+ return { status: "invalid" };
217
+ }
218
+ if (!response.ok) {
219
+ return { status: "unavailable" };
220
+ }
221
+ const body = (await response.json().catch(() => undefined));
222
+ const token = body?.token;
223
+ if (typeof token !== "string" || token === "") {
224
+ return { status: "unavailable" };
225
+ }
226
+ const expiresAt = tokenExpiry(token);
227
+ if (expiresAt !== undefined) {
228
+ if (cache.size >= SESSION_CACHE_SIZE) {
229
+ cache.delete(cache.keys().next().value);
230
+ }
231
+ cache.set(value, { token, refreshAt: expiresAt - SESSION_TOKEN_MARGIN_MS });
232
+ }
233
+ return { status: "token", token };
234
+ };
235
+ }
236
+ /** When a token expires, in milliseconds; `undefined` for a token without a readable `exp`. */
237
+ function tokenExpiry(token) {
238
+ try {
239
+ const { exp } = decodeJwt(token);
240
+ return typeof exp === "number" ? exp * 1_000 : undefined;
241
+ }
242
+ catch {
243
+ return undefined;
244
+ }
245
+ }
246
+ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
247
+ /**
248
+ * The origin the browser addressed. Vercel and the app's development proxy name it in the forwarded
249
+ * headers; otherwise it is the request URL's, which Nitro passes on whole.
250
+ */
251
+ function requestOrigin(request) {
252
+ const first = (value) => value?.split(",")[0]?.trim() || undefined;
253
+ const url = URL.canParse(request.originalUrl) ? new URL(request.originalUrl) : undefined;
254
+ const host = first(request.headers["x-forwarded-host"]) ?? url?.host ?? request.headers.host;
255
+ if (host === undefined || host === "") {
256
+ return undefined;
257
+ }
258
+ const forwardedProto = first(request.headers["x-forwarded-proto"]);
259
+ const protocol = forwardedProto === "http" || forwardedProto === "https"
260
+ ? forwardedProto
261
+ : (url?.protocol.slice(0, -1) ?? "https");
262
+ return `${protocol}://${host}`;
263
+ }
264
+ /**
265
+ * A request the session cookie may authenticate: any read, and a change only when the browser says
266
+ * it comes from the app's own origin. Browsers send `Origin` with every cross-origin request and
267
+ * with same-origin `POST`s, so a change without one is refused too.
268
+ */
269
+ function sessionMayAuthenticate(request) {
270
+ if (SAFE_METHODS.has(request.method)) {
271
+ return true;
272
+ }
273
+ const origin = request.headers.origin;
274
+ return origin !== undefined && origin === requestOrigin(request);
275
+ }
176
276
  /** Resolves a bearer credential to a principal. */
177
277
  export function authenticate(credential, verifyToken) {
178
278
  return Effect.gen(function* () {
@@ -198,14 +298,48 @@ export function authenticate(credential, verifyToken) {
198
298
  : { status: "authenticated", principal: member }), Effect.catchTag("NotInvited", () => Effect.succeed({ status: "not_invited" })));
199
299
  });
200
300
  }
201
- /** The {@link Authentication} middleware, verifying tokens with `verifyToken`. */
202
- export function authenticationLayer(verifyToken) {
301
+ /**
302
+ * Resolves the Neon Auth session cookie to a principal through the session's token. `undefined`
303
+ * when the request has no session cookie, or the service no Neon Auth.
304
+ */
305
+ function authenticateSession(request, exchangeSession, verifyToken) {
306
+ return Effect.gen(function* () {
307
+ const session = request.cookies[NEON_AUTH_SESSION_COOKIE];
308
+ const origin = requestOrigin(request);
309
+ if (exchangeSession === undefined || session === undefined || session === "") {
310
+ return undefined;
311
+ }
312
+ if (origin === undefined || !sessionMayAuthenticate(request)) {
313
+ return { status: "cross_origin" };
314
+ }
315
+ const exchange = yield* Effect.promise(() => exchangeSession(Redacted.make(session), origin));
316
+ switch (exchange.status) {
317
+ case "token":
318
+ return yield* authenticate(Redacted.make(exchange.token), verifyToken);
319
+ case "invalid":
320
+ return { status: "rejected" };
321
+ case "unavailable":
322
+ return { status: "unavailable" };
323
+ }
324
+ });
325
+ }
326
+ /**
327
+ * The {@link Authentication} middleware, verifying tokens with `verifyToken`. With `exchangeSession`,
328
+ * a request without a bearer token may authenticate with the app's Neon Auth session cookie.
329
+ */
330
+ export function authenticationLayer(verifyToken, exchangeSession) {
203
331
  return Layer.effect(Authentication, Effect.gen(function* () {
204
332
  const backend = yield* OntologyBackend;
205
333
  return Authentication.of({
206
334
  bearer: (httpEffect, { credential }) => Effect.gen(function* () {
207
335
  const requestId = yield* RequestId;
208
- const outcome = yield* authenticate(credential, verifyToken).pipe(Effect.provideService(OntologyBackend, backend), Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause)
336
+ const request = yield* HttpServerRequest.HttpServerRequest;
337
+ const outcome = yield* Effect.gen(function* () {
338
+ const fromSession = Redacted.value(credential) === ""
339
+ ? yield* authenticateSession(request, exchangeSession, verifyToken)
340
+ : undefined;
341
+ return fromSession ?? (yield* authenticate(credential, verifyToken));
342
+ }).pipe(Effect.provideService(OntologyBackend, backend), Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause)
209
343
  ? Effect.failCause(cause)
210
344
  : Effect.logError("ontology.authentication.failed", cause).pipe(Effect.annotateLogs({ requestId }), Effect.as({ status: "failed" }))));
211
345
  switch (outcome.status) {
@@ -217,6 +351,8 @@ export function authenticationLayer(verifyToken) {
217
351
  return toServerResponse(unauthorized(requestId, "The bearer token is invalid, expired or revoked."));
218
352
  case "not_invited":
219
353
  return toServerResponse(notInvited(requestId));
354
+ case "cross_origin":
355
+ return toServerResponse(crossOriginSession(requestId));
220
356
  case "unavailable":
221
357
  return yield* Effect.logWarning("ontology.authentication.unavailable").pipe(Effect.annotateLogs({ requestId }), Effect.as(toServerResponse(unavailable(requestId))));
222
358
  case "failed":
@@ -1,4 +1,5 @@
1
1
  import { Redacted, Schema } from "effect";
2
+ import { BlobStorageBinding } from "../storage/provider.js";
2
3
  import type { Principal } from "../runtime.js";
3
4
  import { type ConnectionTransportFactory, type LocalConnectionContext } from "./connections.js";
4
5
  import type { OntologyServiceRuntime } from "./runtime-contract.js";
@@ -29,6 +30,8 @@ export interface NeonAuthEnvironment {
29
30
  export interface OntologyEnvironment {
30
31
  /** The pooled connection string of the `ontology_app` role. */
31
32
  readonly databaseUrl: Redacted.Redacted<string>;
33
+ readonly blobStorage?: Redacted.Redacted<BlobStorageBinding>;
34
+ readonly storageMaintenanceKey?: Redacted.Redacted<string>;
32
35
  /** The control plane: it issues app tokens and serves their JWKS. */
33
36
  readonly segmentstreamUrl: string;
34
37
  /** App tokens must be issued for this application: `aud: app:<application id>`. */
@@ -1,4 +1,5 @@
1
1
  import { Redacted, Schema } from "effect";
2
+ import { BlobStorageBinding } from "../storage/provider.js";
2
3
  import { LocalConnectionContextError, readLocalConnectionContext, RUNTIME_CONTEXT_FD, } from "./connections.js";
3
4
  /** The environment of the workspace deployment's ontology service. */
4
5
  export const ENVIRONMENT_VARIABLES = {
@@ -83,12 +84,29 @@ export function configFromEnv(environment = process.env) {
83
84
  }
84
85
  const neonAuth = neonAuthFromEnv(read);
85
86
  const localConnections = localConnectionsFromEnv(environment);
87
+ let blobStorage;
88
+ if (read("SEGMENTSTREAM_ONTOLOGY_STORAGE")) {
89
+ try {
90
+ blobStorage = Redacted.make(Schema.decodeUnknownSync(BlobStorageBinding)(JSON.parse(read("SEGMENTSTREAM_ONTOLOGY_STORAGE"))));
91
+ }
92
+ catch {
93
+ throw new OntologyServiceConfigError({
94
+ message: "SEGMENTSTREAM_ONTOLOGY_STORAGE is invalid.",
95
+ });
96
+ }
97
+ }
86
98
  return {
87
99
  databaseUrl: checkDatabaseUrl(ENVIRONMENT_VARIABLES.databaseUrl, read(ENVIRONMENT_VARIABLES.databaseUrl)),
88
100
  segmentstreamUrl: normalizeUrl(ENVIRONMENT_VARIABLES.segmentstreamUrl, read(ENVIRONMENT_VARIABLES.segmentstreamUrl)),
89
101
  applicationId: read(ENVIRONMENT_VARIABLES.applicationId),
90
102
  workspaceId: read(ENVIRONMENT_VARIABLES.workspaceId),
91
103
  organizationId: read(ENVIRONMENT_VARIABLES.organizationId),
104
+ ...(blobStorage === undefined ? {} : { blobStorage }),
105
+ ...(read("SEGMENTSTREAM_ONTOLOGY_STORAGE_MAINTENANCE_KEY")
106
+ ? {
107
+ storageMaintenanceKey: Redacted.make(read("SEGMENTSTREAM_ONTOLOGY_STORAGE_MAINTENANCE_KEY")),
108
+ }
109
+ : {}),
92
110
  ...(neonAuth === undefined ? {} : { neonAuth }),
93
111
  ...(localConnections === undefined ? {} : { localConnections }),
94
112
  };
@@ -0,0 +1,9 @@
1
+ import { HttpRouter } from "effect/unstable/http";
2
+ /** The API reference page: `GET /api/ontology/docs`. */
3
+ export declare const DOCS_PATH = "/api/ontology/docs";
4
+ /**
5
+ * The API reference at `/api/ontology/docs`: Scalar over the service's own `openapi.json`. The page
6
+ * is public and holds no part of the ontology: it loads the document as the reader, with the app's
7
+ * session cookie when they are signed in to the app, or with a token they paste.
8
+ */
9
+ export declare const DocsRoute: import("effect/Layer").Layer<never, never, HttpRouter.HttpRouter>;
@@ -0,0 +1,197 @@
1
+ import { Effect } from "effect";
2
+ import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
3
+ import { BASE_PATH } from "./api.js";
4
+ /** The API reference page: `GET /api/ontology/docs`. */
5
+ export const DOCS_PATH = `${BASE_PATH}/docs`;
6
+ const OPENAPI_PATH = `${BASE_PATH}/openapi.json`;
7
+ /**
8
+ * Scalar's API reference, pinned with its integrity hash: a different file never runs. Bump both
9
+ * together: `curl -sL <url> | openssl dgst -sha384 -binary | openssl base64 -A`.
10
+ */
11
+ const SCALAR_SCRIPT = {
12
+ src: "https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.73.1/dist/browser/standalone.js",
13
+ integrity: "sha384-kYDGzV91Jnn3TbHINV3nt54riK2uMJDfN5Al8dAkz4FssELTBWbD8rgw32sTKfOi",
14
+ };
15
+ /**
16
+ * Scalar without its hosted extras: no telemetry, AI agent, MCP button, developer tools or fonts
17
+ * from scalar.com. Requests from the page go to the ontology itself, never through Scalar's proxy.
18
+ */
19
+ const SCALAR_CONFIGURATION = {
20
+ telemetry: false,
21
+ agent: { disabled: true },
22
+ mcp: { disabled: true },
23
+ showDeveloperTools: "never",
24
+ withDefaultFonts: false,
25
+ hideClientButton: true,
26
+ documentDownloadType: "json",
27
+ };
28
+ /** The key the page keeps a pasted token under, for this tab only. */
29
+ const TOKEN_STORAGE_KEY = "segmentstream-ontology-docs-token";
30
+ /** JSON inside a `<script>`: nothing in it can close the element. */
31
+ function scriptJson(value) {
32
+ return JSON.stringify(value).replace(/</g, "\\u003c");
33
+ }
34
+ function contentSecurityPolicy(nonce) {
35
+ return [
36
+ "default-src 'none'",
37
+ `script-src 'nonce-${nonce}' https://cdn.jsdelivr.net`,
38
+ // Scalar injects its styles at runtime.
39
+ "style-src 'self' 'unsafe-inline'",
40
+ "img-src 'self' data: blob: https:",
41
+ "font-src 'self' data:",
42
+ "connect-src 'self'",
43
+ "worker-src 'self' blob:",
44
+ "base-uri 'none'",
45
+ "form-action 'none'",
46
+ "frame-ancestors 'none'",
47
+ ].join("; ");
48
+ }
49
+ function page(nonce) {
50
+ const settings = scriptJson({
51
+ openapi: OPENAPI_PATH,
52
+ storageKey: TOKEN_STORAGE_KEY,
53
+ configuration: SCALAR_CONFIGURATION,
54
+ });
55
+ return `<!doctype html>
56
+ <html lang="en">
57
+ <head>
58
+ <meta charset="utf-8" />
59
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
60
+ <meta name="robots" content="noindex" />
61
+ <title>Ontology API</title>
62
+ <style>
63
+ #sign-in {
64
+ font: 14px/1.5 system-ui, sans-serif;
65
+ max-width: 28rem;
66
+ margin: 15vh auto;
67
+ padding: 0 1.5rem;
68
+ color: #18181b;
69
+ }
70
+ #sign-in h1 { font-size: 1.25rem; margin: 0 0 .5rem; }
71
+ #sign-in p { margin: 0 0 1rem; color: #52525b; }
72
+ #sign-in label { display: block; font-weight: 500; margin-bottom: .25rem; }
73
+ #sign-in input {
74
+ box-sizing: border-box;
75
+ width: 100%;
76
+ padding: .5rem .625rem;
77
+ border: 1px solid #d4d4d8;
78
+ border-radius: .375rem;
79
+ font: inherit;
80
+ }
81
+ #sign-in button {
82
+ margin-top: .75rem;
83
+ padding: .5rem .875rem;
84
+ border: 0;
85
+ border-radius: .375rem;
86
+ background: #18181b;
87
+ color: #fff;
88
+ font: inherit;
89
+ cursor: pointer;
90
+ }
91
+ #sign-in [role="alert"] { margin-top: .75rem; color: #b91c1c; }
92
+ </style>
93
+ </head>
94
+ <body>
95
+ <main id="sign-in" hidden>
96
+ <form>
97
+ <h1>Ontology API</h1>
98
+ <p>Sign in to the app in this browser, or paste a bearer token or an API key (ssk_…).</p>
99
+ <label for="token">Token or API key</label>
100
+ <input id="token" type="password" autocomplete="off" spellcheck="false" required />
101
+ <button type="submit">Open the reference</button>
102
+ <p role="alert"></p>
103
+ </form>
104
+ </main>
105
+ <div id="app"></div>
106
+ <script
107
+ nonce="${nonce}"
108
+ src="${SCALAR_SCRIPT.src}"
109
+ integrity="${SCALAR_SCRIPT.integrity}"
110
+ crossorigin="anonymous"
111
+ ></script>
112
+ <script nonce="${nonce}">
113
+ (() => {
114
+ const settings = ${settings};
115
+ const signIn = document.getElementById("sign-in");
116
+ const form = signIn.querySelector("form");
117
+ const input = document.getElementById("token");
118
+ const alert = signIn.querySelector("[role=alert]");
119
+
120
+ async function load(token) {
121
+ const response = await fetch(settings.openapi, {
122
+ credentials: "same-origin",
123
+ headers: token ? { authorization: "Bearer " + token } : {},
124
+ });
125
+
126
+ if (response.ok) {
127
+ return { spec: await response.json() };
128
+ }
129
+
130
+ const body = await response.json().catch(() => ({}));
131
+
132
+ return { status: response.status, detail: body.detail };
133
+ }
134
+
135
+ function show(spec, token) {
136
+ signIn.hidden = true;
137
+ window.Scalar.createApiReference(document.getElementById("app"), {
138
+ ...settings.configuration,
139
+ content: spec,
140
+ authentication: {
141
+ preferredSecurityScheme: "bearer",
142
+ ...(token ? { securitySchemes: { bearer: { token } } } : {}),
143
+ },
144
+ });
145
+ }
146
+
147
+ async function open(token) {
148
+ let result;
149
+
150
+ try {
151
+ result = await load(token);
152
+ } catch {
153
+ result = { status: 0 };
154
+ }
155
+
156
+ if (result.spec !== undefined) {
157
+ if (token) {
158
+ sessionStorage.setItem(settings.storageKey, token);
159
+ }
160
+ show(result.spec, token);
161
+
162
+ return;
163
+ }
164
+
165
+ sessionStorage.removeItem(settings.storageKey);
166
+ signIn.hidden = false;
167
+ alert.textContent =
168
+ !token && result.status === 401
169
+ ? ""
170
+ : (result.detail ?? "The reference could not be loaded. Try again shortly.");
171
+ }
172
+
173
+ form.addEventListener("submit", (event) => {
174
+ event.preventDefault();
175
+ open(input.value.trim());
176
+ });
177
+
178
+ open(sessionStorage.getItem(settings.storageKey) ?? "");
179
+ })();
180
+ </script>
181
+ </body>
182
+ </html>
183
+ `;
184
+ }
185
+ /**
186
+ * The API reference at `/api/ontology/docs`: Scalar over the service's own `openapi.json`. The page
187
+ * is public and holds no part of the ontology: it loads the document as the reader, with the app's
188
+ * session cookie when they are signed in to the app, or with a token they paste.
189
+ */
190
+ export const DocsRoute = HttpRouter.add("GET", DOCS_PATH, Effect.sync(() => {
191
+ const nonce = crypto.randomUUID().replaceAll("-", "");
192
+ return HttpServerResponse.html(page(nonce)).pipe(HttpServerResponse.setHeaders({
193
+ "content-security-policy": contentSecurityPolicy(nonce),
194
+ "referrer-policy": "no-referrer",
195
+ "x-content-type-options": "nosniff",
196
+ }));
197
+ }));
@@ -51,6 +51,11 @@ export declare function unauthorized(requestId: string, detail: string): ErrorRe
51
51
  * The detail is written for the person, since the app shows it.
52
52
  */
53
53
  export declare function notInvited(requestId: string): ErrorResponse;
54
+ /**
55
+ * A request that changes something came with the session cookie but not from the app's own pages.
56
+ * The cookie rides along with any request to the app's domain, so only its origin can vouch for it.
57
+ */
58
+ export declare function crossOriginSession(requestId: string): ErrorResponse;
54
59
  export declare function unavailable(requestId: string): ErrorResponse;
55
60
  export declare function internalError(requestId: string): ErrorResponse;
56
61
  export declare function routeNotFound(requestId: string): ErrorResponse;
@@ -67,6 +67,20 @@ export function notInvited(requestId) {
67
67
  body: { error: "not_invited", detail: "Ask your administrator for an invitation.", requestId },
68
68
  };
69
69
  }
70
+ /**
71
+ * A request that changes something came with the session cookie but not from the app's own pages.
72
+ * The cookie rides along with any request to the app's domain, so only its origin can vouch for it.
73
+ */
74
+ export function crossOriginSession(requestId) {
75
+ return {
76
+ status: 403,
77
+ body: {
78
+ error: "forbidden",
79
+ detail: "The session cookie only authenticates requests from the app's own pages; send a bearer token.",
80
+ requestId,
81
+ },
82
+ };
83
+ }
70
84
  export function unavailable(requestId) {
71
85
  return {
72
86
  status: 503,
@@ -1,6 +1,8 @@
1
1
  import { HttpServerResponse } from "effect/unstable/http";
2
2
  import { Effect, Layer, Schema } from "effect";
3
3
  import { HttpApiBuilder, OpenApi } from "effect/unstable/httpapi";
4
+ import { STORAGE_GROUP } from "../storage/api.js";
5
+ import { Conflict } from "../errors.js";
4
6
  import { editsFromWire, generatedResultFromWire, recordFromWire } from "../authoring-records.js";
5
7
  import { nameToWire } from "../authoring-names.js";
6
8
  import { principalFromWire } from "../authoring.js";
@@ -181,7 +183,7 @@ function runAction(planned, { name, generated }, ontology, hook, connections) {
181
183
  action: name,
182
184
  input: envelope.input,
183
185
  principal,
184
- options: { validateOnly },
186
+ options: { ...envelope.options, validateOnly },
185
187
  requestId,
186
188
  connections: connections({
187
189
  identityToken: identityToken(request),
@@ -219,6 +221,18 @@ function entityHandlers(built, planned, hooks, targets, connections) {
219
221
  .handle("query", (request) => find(toQuery(request.payload)))
220
222
  .handle("get", (request) => readOwn.pipe(Effect.andThen(() => backend.get({ entity: planned.name, id: request.params.id }))))
221
223
  .handle("history", (request) => readOwn.pipe(Effect.andThen(() => backend.history({ entity: planned.name, id: request.params.id, ...request.query })), Effect.map(page)));
224
+ for (const property of planned.resolved.properties.filter((property) => property.definition.type === "blob")) {
225
+ registered = registered.handle(`blobs.${property.name}`, (request) => Effect.gen(function* () {
226
+ const principal = yield* CurrentPrincipal;
227
+ if (!backend.blobs) {
228
+ return yield* new Conflict({
229
+ entity: planned.name,
230
+ message: "File storage is not configured.",
231
+ });
232
+ }
233
+ return yield* backend.blobs.download(planned.name, request.params.id, property.name, request.payload, principal);
234
+ }));
235
+ }
222
236
  for (const traversal of planned.traversals) {
223
237
  registered = registered.handle(`related.${traversal.name}`, (request) => traverse(planned, traversal, request, targets).pipe(Effect.provideService(OntologyBackend, backend)));
224
238
  }
@@ -324,11 +338,36 @@ function serviceHandlers(built) {
324
338
  .handle("signInCheck", checkSignIn(backend, signInLimiter));
325
339
  }));
326
340
  }
341
+ function storageHandlers(built) {
342
+ return dynamicGroup(built.api, STORAGE_GROUP, (handlers) => Effect.gen(function* () {
343
+ const backend = yield* OntologyBackend;
344
+ const configured = () => backend.blobs
345
+ ? Effect.succeed(backend.blobs)
346
+ : Effect.fail(new Conflict({ entity: "upload", message: "File storage is not configured." }));
347
+ let registered = handlers.handle("begin", (request) => Effect.gen(function* () {
348
+ const principal = yield* CurrentPrincipal;
349
+ const blobs = yield* configured();
350
+ return yield* blobs.begin(request.payload, principal);
351
+ }));
352
+ for (const method of ["status", "part", "complete", "cancel"]) {
353
+ registered = registered.handle(method, (request) => Effect.gen(function* () {
354
+ const principal = yield* CurrentPrincipal;
355
+ const blobs = yield* configured();
356
+ return yield* blobs[method](request.params.id, principal);
357
+ }));
358
+ }
359
+ return registered.handle("maintain", () => backend.maintainBlobs ??
360
+ Effect.fail(new Conflict({ entity: "upload", message: "File storage is not configured." })));
361
+ }));
362
+ }
327
363
  /** The handlers of every group of the API; they require {@link OntologyBackend}. */
328
364
  export function handlersLayer(built, hooks, connections) {
329
365
  const targets = relationshipTargets(built.plan);
330
366
  const layers = [
331
367
  serviceHandlers(built),
368
+ ...(built.plan.ontology.manifest.entities.some((entity) => entity.properties.some((property) => property.type === "blob"))
369
+ ? [storageHandlers(built)]
370
+ : []),
332
371
  ...built.plan.entities.map((planned) => entityHandlers(built, planned, hooks, targets, connections)),
333
372
  ];
334
373
  const [first, ...rest] = layers;
@@ -1,4 +1,6 @@
1
1
  import { Context, Schema, type Effect, type Layer, type Redacted } from "effect";
2
+ import type { BlobRuntime } from "../storage/sessions.js";
3
+ import type { BlobStorageBinding } from "../storage/provider.js";
2
4
  import type { AnyAction } from "../action.js";
3
5
  import type { ResolvedEntity } from "../analyze.js";
4
6
  import type { ConnectionError, ConnectionTransport } from "../connection.js";
@@ -23,6 +25,8 @@ export interface RunActionInput {
23
25
  readonly principal: Principal;
24
26
  readonly options: {
25
27
  readonly validateOnly?: boolean;
28
+ readonly idempotencyKey?: string;
29
+ readonly createIntent?: string;
26
30
  };
27
31
  /**
28
32
  * Runs once the action has committed, outside its transaction, before `runAction` returns. It
@@ -63,6 +67,10 @@ declare const NotInvited_base: Schema.Class<NotInvited, Schema.TaggedStruct<"Not
63
67
  export declare class NotInvited extends NotInvited_base {
64
68
  }
65
69
  declare const OntologyBackend_base: Context.ServiceClass<OntologyBackend, "@segmentstream-cloud/ontology/OntologyBackend", {
70
+ readonly blobs?: BlobRuntime;
71
+ readonly maintainBlobs?: Effect.Effect<{
72
+ readonly removed: number;
73
+ }, unknown>;
66
74
  readonly runAction: (input: RunActionInput) => Effect.Effect<ActionOutcome, PermissionDenied | ActionRejected | Conflict | NotFound | ConnectionError>;
67
75
  readonly get: (input: {
68
76
  readonly entity: string;
@@ -111,6 +119,7 @@ export interface OntologyServiceRuntime {
111
119
  readonly connect: (input: {
112
120
  readonly ontology: Ontology;
113
121
  readonly databaseUrl: Redacted.Redacted<string>;
122
+ readonly blobStorage?: Redacted.Redacted<BlobStorageBinding>;
114
123
  }) => Layer.Layer<OntologyBackend, unknown>;
115
124
  }
116
125
  export {};
@@ -124,6 +124,8 @@ export declare const ActionEnvelope: Schema.Codec<{
124
124
  readonly input: unknown;
125
125
  readonly options?: {
126
126
  readonly validateOnly?: boolean;
127
+ readonly idempotencyKey?: string;
128
+ readonly createIntent?: string;
127
129
  };
128
130
  }>;
129
131
  export declare const ActionResponse: Schema.Struct<{
@@ -151,7 +153,7 @@ export declare const Catalog: Schema.decodeTo<Schema.Struct<{
151
153
  readonly column: Schema.optionalKey<Schema.String>;
152
154
  readonly scope: Schema.optionalKey<Schema.$Array<Schema.String>>;
153
155
  readonly name: Schema.String;
154
- readonly type: Schema.Literals<readonly ["text", "email", "url", "integer", "number", "boolean", "date", "timestamp", "select", "json"]>;
156
+ readonly type: Schema.Literals<readonly ["text", "email", "url", "integer", "number", "boolean", "date", "timestamp", "select", "json", "blob"]>;
155
157
  readonly label: Schema.String;
156
158
  readonly description: Schema.optionalKey<Schema.String>;
157
159
  readonly optional: Schema.Boolean;
@@ -163,6 +165,8 @@ export declare const Catalog: Schema.decodeTo<Schema.Struct<{
163
165
  readonly min: Schema.optionalKey<Schema.Number>;
164
166
  readonly max: Schema.optionalKey<Schema.Number>;
165
167
  readonly maxLength: Schema.optionalKey<Schema.Number>;
168
+ readonly accept: Schema.optionalKey<Schema.$Array<Schema.String>>;
169
+ readonly maxBytes: Schema.optionalKey<Schema.Number>;
166
170
  readonly options: Schema.optionalKey<Schema.$Array<Schema.Struct<{
167
171
  readonly value: Schema.String;
168
172
  readonly label: Schema.String;
@@ -171,6 +175,7 @@ export declare const Catalog: Schema.decodeTo<Schema.Struct<{
171
175
  }>, {
172
176
  readonly renamedFrom: "renamed_from";
173
177
  readonly maxLength: "max_length";
178
+ readonly maxBytes: "max_bytes";
174
179
  }>>;
175
180
  readonly relationships: Schema.$Array<Schema.encodeKeys<Schema.Struct<{
176
181
  readonly on: Schema.optionalKey<Schema.encodeKeys<Schema.Struct<{
@@ -259,7 +264,7 @@ export declare const Catalog: Schema.decodeTo<Schema.Struct<{
259
264
  readonly column: Schema.optionalKey<Schema.String>;
260
265
  readonly scope: Schema.optionalKey<Schema.$Array<Schema.String>>;
261
266
  readonly name: Schema.String;
262
- readonly type: Schema.Literals<readonly ["text", "email", "url", "integer", "number", "boolean", "date", "timestamp", "select", "json"]>;
267
+ readonly type: Schema.Literals<readonly ["text", "email", "url", "integer", "number", "boolean", "date", "timestamp", "select", "json", "blob"]>;
263
268
  readonly label: Schema.String;
264
269
  readonly description: Schema.optionalKey<Schema.String>;
265
270
  readonly optional: Schema.Boolean;
@@ -271,6 +276,8 @@ export declare const Catalog: Schema.decodeTo<Schema.Struct<{
271
276
  readonly min: Schema.optionalKey<Schema.Number>;
272
277
  readonly max: Schema.optionalKey<Schema.Number>;
273
278
  readonly maxLength: Schema.optionalKey<Schema.Number>;
279
+ readonly accept: Schema.optionalKey<Schema.$Array<Schema.String>>;
280
+ readonly maxBytes: Schema.optionalKey<Schema.Number>;
274
281
  readonly options: Schema.optionalKey<Schema.$Array<Schema.Struct<{
275
282
  readonly value: Schema.String;
276
283
  readonly label: Schema.String;
@@ -279,6 +286,7 @@ export declare const Catalog: Schema.decodeTo<Schema.Struct<{
279
286
  }>, {
280
287
  readonly renamedFrom: "renamed_from";
281
288
  readonly maxLength: "max_length";
289
+ readonly maxBytes: "max_bytes";
282
290
  }>>;
283
291
  readonly relationships: Schema.$Array<Schema.encodeKeys<Schema.Struct<{
284
292
  readonly on: Schema.optionalKey<Schema.encodeKeys<Schema.Struct<{