@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 +7 -1
- package/dist/names.d.ts +1 -1
- package/dist/names.js +1 -0
- package/dist/service/authentication.d.ts +33 -4
- package/dist/service/authentication.js +142 -6
- package/dist/service/docs.d.ts +9 -0
- package/dist/service/docs.js +197 -0
- package/dist/service/errors.d.ts +5 -0
- package/dist/service/errors.js +14 -0
- package/dist/service/service.js +5 -3
- package/package.json +1 -1
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
|
|
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
|
@@ -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
|
|
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
|
-
/**
|
|
97
|
-
|
|
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
|
|
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
|
-
/**
|
|
202
|
-
|
|
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
|
|
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
|
+
}));
|
package/dist/service/errors.d.ts
CHANGED
|
@@ -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;
|
package/dist/service/errors.js
CHANGED
|
@@ -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,
|
package/dist/service/service.js
CHANGED
|
@@ -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.
|
|
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",
|