@segmentstream-cloud/ontology 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -135,7 +135,7 @@ entity, the verb or both: `lead:*`, `*:read`, `*:*`.
135
135
  Members hold `member` and their assigned roles; an API key holds exactly the roles it was
136
136
  created with. A role the ontology no longer declares grants nothing.
137
137
  - The catalog, `describe`, `graph`, `openapi.json` and `me` need only authentication;
138
- `status` and `sign-in/check` need none. `?as=me` lists only the actions the caller may run.
138
+ `status`, `sign-in/check` and the `docs` page need none. `?as=me` lists only the actions the caller may run.
139
139
  - `check` fails on a role without permissions, on a permission whose entity or verb does not
140
140
  exist, and on `allow`, which version 0.2 replaced. It warns on an action no role may run
141
141
  and on `*:*`.
@@ -155,6 +155,12 @@ export function handleOntologyRequest(request: Request): Promise<Response> {
155
155
  }
156
156
  ```
157
157
 
158
+ `GET /api/ontology/docs` serves a Scalar API reference over `openapi.json`. A browser signed
159
+ in to the app authenticates with the app's Neon Auth session cookie
160
+ (`__Secure-neon-auth.session_token`), which the service exchanges at Neon Auth for the
161
+ session's token and verifies like a bearer token. A bearer token always takes precedence. The
162
+ cookie authenticates a `POST` only when its `Origin` is the app's own.
163
+
158
164
  `handler(request)` takes a web `Request` and returns a `Response`. `dispose()` closes the
159
165
  database pool. `configFromEnv()` reads these variables (`ENVIRONMENT_VARIABLES`, and the
160
166
  optional `NEON_AUTH_ENVIRONMENT_VARIABLES`). It throws `OntologyServiceConfigError` when
package/dist/names.d.ts CHANGED
@@ -21,7 +21,7 @@ export declare const HISTORY_SUFFIX = "_history";
21
21
  /** The combinators of the query language, which take the place of field names in a `where`. */
22
22
  export declare const RESERVED_MEMBER_NAMES: readonly ["and", "or", "not"];
23
23
  /** Routes of the ontology service under `/api/ontology/`, which an entity's routes would shadow. */
24
- export declare const RESERVED_ENTITY_NAMES: readonly ["graph", "status", "describe", "openapi", "mcp", "me"];
24
+ export declare const RESERVED_ENTITY_NAMES: readonly ["graph", "status", "describe", "openapi", "docs", "mcp", "me"];
25
25
  /** `/api/ontology/<entity>/{id}/history`, which a relationship's traversal would shadow. */
26
26
  export declare const RESERVED_RELATIONSHIP_NAMES: readonly ["history"];
27
27
  export declare function isSystemProperty(name: string): boolean;
package/dist/names.js CHANGED
@@ -54,6 +54,7 @@ export const RESERVED_ENTITY_NAMES = [
54
54
  "status",
55
55
  "describe",
56
56
  "openapi",
57
+ "docs",
57
58
  "mcp",
58
59
  "me",
59
60
  ];
@@ -23,8 +23,8 @@ declare const Authentication_base: HttpApiMiddleware.ServiceClass<Authentication
23
23
  }, CurrentPrincipal, readonly [import("effect/Schema").Top, import("effect/Schema").Top, import("effect/Schema").Top], RequestId>>;
24
24
  /**
25
25
  * Authenticates every ontology route except `status`: a Neon Auth token, a SegmentStream app token
26
- * or an API key, all as `Authorization: Bearer …`. Failures answer the request directly, before its
27
- * input is decoded.
26
+ * or an API key, all as `Authorization: Bearer …`; without one, the app's Neon Auth session cookie.
27
+ * Failures answer the request directly, before its input is decoded.
28
28
  */
29
29
  export declare class Authentication extends Authentication_base {
30
30
  }
@@ -79,6 +79,32 @@ export declare function verifyByIssuer(verifiers: ReadonlyMap<string, TokenVerif
79
79
  export declare function tokenVerifier(config: TokenVerifierConfig & {
80
80
  readonly neonAuth?: NeonAuthEnvironment | undefined;
81
81
  }): TokenVerifier;
82
+ /**
83
+ * The session cookie the app's Neon Auth sets on the app's domain. The ontology service shares the
84
+ * domain, so a signed-in browser sends it here too: the docs page relies on it.
85
+ */
86
+ export declare const NEON_AUTH_SESSION_COOKIE = "__Secure-neon-auth.session_token";
87
+ export type SessionExchangeResult = {
88
+ readonly status: "token";
89
+ readonly token: string;
90
+ } | {
91
+ readonly status: "invalid";
92
+ } | {
93
+ readonly status: "unavailable";
94
+ };
95
+ /** Turns a Neon Auth session cookie into the session's token; `origin` is the app's. */
96
+ export type SessionExchange = (session: Redacted.Redacted<string>, origin: string) => Promise<SessionExchangeResult>;
97
+ /**
98
+ * Exchanges a session cookie for its token at Neon Auth's `GET <base URL>/token`, as the app's
99
+ * `@neondatabase/auth` does, and reuses the token until shortly before it expires. The token is then
100
+ * verified like any bearer token, so the exchange never vouches for a session on its own.
101
+ */
102
+ export declare function neonAuthSessionExchange(config: {
103
+ readonly baseUrl: string;
104
+ }, options?: {
105
+ readonly fetch?: typeof fetch;
106
+ readonly now?: () => number;
107
+ }): SessionExchange;
82
108
  type Authenticated = {
83
109
  readonly status: "authenticated";
84
110
  readonly principal: Principal;
@@ -93,6 +119,9 @@ type Authenticated = {
93
119
  };
94
120
  /** Resolves a bearer credential to a principal. */
95
121
  export declare function authenticate(credential: Redacted.Redacted<string>, verifyToken: TokenVerifier): Effect.Effect<Authenticated, never, OntologyBackend>;
96
- /** The {@link Authentication} middleware, verifying tokens with `verifyToken`. */
97
- export declare function authenticationLayer(verifyToken: TokenVerifier): Layer.Layer<Authentication, never, OntologyBackend>;
122
+ /**
123
+ * The {@link Authentication} middleware, verifying tokens with `verifyToken`. With `exchangeSession`,
124
+ * a request without a bearer token may authenticate with the app's Neon Auth session cookie.
125
+ */
126
+ export declare function authenticationLayer(verifyToken: TokenVerifier, exchangeSession?: SessionExchange): Layer.Layer<Authentication, never, OntologyBackend>;
98
127
  export {};
@@ -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":
@@ -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,
@@ -4,10 +4,11 @@ import { HttpApiBuilder } from "effect/unstable/httpapi";
4
4
  import { nameToWire } from "../authoring-names.js";
5
5
  import { sqlServiceRuntime } from "../runtime/service-binding.js";
6
6
  import { ErrorResponsesLive, buildApi } from "./api.js";
7
- import { authenticationLayer, tokenVerifier } from "./authentication.js";
7
+ import { authenticationLayer, neonAuthSessionExchange, tokenVerifier } from "./authentication.js";
8
8
  import { OntologyServiceConfigError, } from "./config.js";
9
9
  import { RequestId, failureResponse, internalError, errorBodyForWire, routeNotFound, toServerResponse, } from "./errors.js";
10
10
  import { deployedConnections, localConnections } from "./connections.js";
11
+ import { DocsRoute } from "./docs.js";
11
12
  import { handlersLayer } from "./handlers.js";
12
13
  import { planApi } from "./plan.js";
13
14
  /** The header every response carries: the hash of the manifest the service serves. */
@@ -62,13 +63,14 @@ export function createOntologyService(ontology, config) {
62
63
  const built = buildApi(plan);
63
64
  const hooks = afterCommitHooks(plan, config.afterCommit);
64
65
  const verifyToken = tokenVerifier(config);
66
+ const exchangeSession = config.neonAuth === undefined ? undefined : neonAuthSessionExchange(config.neonAuth);
65
67
  const backend = runtime.connect({ ontology, databaseUrl: config.databaseUrl });
66
68
  const connections = config.connections ??
67
69
  (config.localConnections === undefined
68
70
  ? deployedConnections(config)
69
71
  : localConnections(config.localConnections));
70
- const routes = HttpApiBuilder.layer(built.api).pipe(Layer.provide(handlersLayer(built, hooks, connections)), Layer.provide([authenticationLayer(verifyToken), ErrorResponsesLive]), Layer.provide(backend));
71
- const application = Layer.mergeAll(routes, UnmatchedRoutes).pipe(Layer.provide(HttpServer.layerServices));
72
+ const routes = HttpApiBuilder.layer(built.api).pipe(Layer.provide(handlersLayer(built, hooks, connections)), Layer.provide([authenticationLayer(verifyToken, exchangeSession), ErrorResponsesLive]), Layer.provide(backend));
73
+ const application = Layer.mergeAll(routes, DocsRoute, UnmatchedRoutes).pipe(Layer.provide(HttpServer.layerServices));
72
74
  const web = HttpRouter.toWebHandler(application, { disableLogger: true });
73
75
  const hash = ontology.manifest.hash;
74
76
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segmentstream-cloud/ontology",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Define a SegmentStream workspace app's ontology: entities, relationships, roles and actions, written with Effect.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "SegmentStream",