@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.
- package/README.md +7 -1
- package/dist/analyze.js +16 -1
- package/dist/blob.d.ts +39 -0
- package/dist/blob.js +49 -0
- package/dist/entity.d.ts +5 -3
- package/dist/index.d.ts +4 -1
- package/dist/index.js +2 -0
- package/dist/manifest.d.ts +23 -7
- package/dist/manifest.js +9 -1
- package/dist/migrate/model.d.ts +7 -2
- package/dist/migrate/plan.js +26 -2
- package/dist/migrate/storage.d.ts +2 -0
- package/dist/migrate/storage.js +13 -0
- package/dist/names.d.ts +1 -1
- package/dist/names.js +1 -0
- package/dist/property.d.ts +10 -1
- package/dist/property.js +12 -1
- package/dist/query.js +6 -1
- package/dist/runtime/generated-actions.js +7 -3
- package/dist/runtime/history.d.ts +3 -0
- package/dist/runtime/pipeline.d.ts +5 -1
- package/dist/runtime/pipeline.js +87 -3
- package/dist/runtime/records.js +9 -0
- package/dist/runtime/service-binding.d.ts +2 -1
- package/dist/runtime/service-binding.js +13 -2
- package/dist/runtime/writes.js +17 -3
- package/dist/service/api.js +15 -0
- package/dist/service/authentication.d.ts +33 -4
- package/dist/service/authentication.js +142 -6
- package/dist/service/config.d.ts +3 -0
- package/dist/service/config.js +18 -0
- 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/handlers.js +40 -1
- package/dist/service/runtime-contract.d.ts +9 -0
- package/dist/service/schemas.d.ts +10 -2
- package/dist/service/schemas.js +18 -2
- package/dist/service/service.js +22 -5
- package/dist/storage/api.d.ts +4 -0
- package/dist/storage/api.js +39 -0
- package/dist/storage/authorization.d.ts +8 -0
- package/dist/storage/authorization.js +30 -0
- package/dist/storage/maintenance-auth.d.ts +20 -0
- package/dist/storage/maintenance-auth.js +24 -0
- package/dist/storage/maintenance.d.ts +7 -0
- package/dist/storage/maintenance.js +50 -0
- package/dist/storage/neon.d.ts +3 -0
- package/dist/storage/neon.js +212 -0
- package/dist/storage/operator.d.ts +10 -0
- package/dist/storage/operator.js +30 -0
- package/dist/storage/provider.d.ts +80 -0
- package/dist/storage/provider.js +20 -0
- package/dist/storage/sessions.d.ts +87 -0
- package/dist/storage/sessions.js +341 -0
- package/dist/storage/tables.d.ts +2 -0
- package/dist/storage/tables.js +55 -0
- package/dist/storage/writes.d.ts +7 -0
- package/dist/storage/writes.js +88 -0
- 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
|
|
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":
|
package/dist/service/config.d.ts
CHANGED
|
@@ -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>`. */
|
package/dist/service/config.js
CHANGED
|
@@ -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
|
+
}));
|
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/handlers.js
CHANGED
|
@@ -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<{
|