@segmentstream-cloud/ontology 0.7.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.
@@ -0,0 +1,197 @@
1
+ import { Effect } from "effect";
2
+ import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
3
+ import { BASE_PATH } from "./api.js";
4
+ /** The API reference page: `GET /api/ontology/docs`. */
5
+ export const DOCS_PATH = `${BASE_PATH}/docs`;
6
+ const OPENAPI_PATH = `${BASE_PATH}/openapi.json`;
7
+ /**
8
+ * Scalar's API reference, pinned with its integrity hash: a different file never runs. Bump both
9
+ * together: `curl -sL <url> | openssl dgst -sha384 -binary | openssl base64 -A`.
10
+ */
11
+ const SCALAR_SCRIPT = {
12
+ src: "https://cdn.jsdelivr.net/npm/@scalar/api-reference@1.73.1/dist/browser/standalone.js",
13
+ integrity: "sha384-kYDGzV91Jnn3TbHINV3nt54riK2uMJDfN5Al8dAkz4FssELTBWbD8rgw32sTKfOi",
14
+ };
15
+ /**
16
+ * Scalar without its hosted extras: no telemetry, AI agent, MCP button, developer tools or fonts
17
+ * from scalar.com. Requests from the page go to the ontology itself, never through Scalar's proxy.
18
+ */
19
+ const SCALAR_CONFIGURATION = {
20
+ telemetry: false,
21
+ agent: { disabled: true },
22
+ mcp: { disabled: true },
23
+ showDeveloperTools: "never",
24
+ withDefaultFonts: false,
25
+ hideClientButton: true,
26
+ documentDownloadType: "json",
27
+ };
28
+ /** The key the page keeps a pasted token under, for this tab only. */
29
+ const TOKEN_STORAGE_KEY = "segmentstream-ontology-docs-token";
30
+ /** JSON inside a `<script>`: nothing in it can close the element. */
31
+ function scriptJson(value) {
32
+ return JSON.stringify(value).replace(/</g, "\\u003c");
33
+ }
34
+ function contentSecurityPolicy(nonce) {
35
+ return [
36
+ "default-src 'none'",
37
+ `script-src 'nonce-${nonce}' https://cdn.jsdelivr.net`,
38
+ // Scalar injects its styles at runtime.
39
+ "style-src 'self' 'unsafe-inline'",
40
+ "img-src 'self' data: blob: https:",
41
+ "font-src 'self' data:",
42
+ "connect-src 'self'",
43
+ "worker-src 'self' blob:",
44
+ "base-uri 'none'",
45
+ "form-action 'none'",
46
+ "frame-ancestors 'none'",
47
+ ].join("; ");
48
+ }
49
+ function page(nonce) {
50
+ const settings = scriptJson({
51
+ openapi: OPENAPI_PATH,
52
+ storageKey: TOKEN_STORAGE_KEY,
53
+ configuration: SCALAR_CONFIGURATION,
54
+ });
55
+ return `<!doctype html>
56
+ <html lang="en">
57
+ <head>
58
+ <meta charset="utf-8" />
59
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
60
+ <meta name="robots" content="noindex" />
61
+ <title>Ontology API</title>
62
+ <style>
63
+ #sign-in {
64
+ font: 14px/1.5 system-ui, sans-serif;
65
+ max-width: 28rem;
66
+ margin: 15vh auto;
67
+ padding: 0 1.5rem;
68
+ color: #18181b;
69
+ }
70
+ #sign-in h1 { font-size: 1.25rem; margin: 0 0 .5rem; }
71
+ #sign-in p { margin: 0 0 1rem; color: #52525b; }
72
+ #sign-in label { display: block; font-weight: 500; margin-bottom: .25rem; }
73
+ #sign-in input {
74
+ box-sizing: border-box;
75
+ width: 100%;
76
+ padding: .5rem .625rem;
77
+ border: 1px solid #d4d4d8;
78
+ border-radius: .375rem;
79
+ font: inherit;
80
+ }
81
+ #sign-in button {
82
+ margin-top: .75rem;
83
+ padding: .5rem .875rem;
84
+ border: 0;
85
+ border-radius: .375rem;
86
+ background: #18181b;
87
+ color: #fff;
88
+ font: inherit;
89
+ cursor: pointer;
90
+ }
91
+ #sign-in [role="alert"] { margin-top: .75rem; color: #b91c1c; }
92
+ </style>
93
+ </head>
94
+ <body>
95
+ <main id="sign-in" hidden>
96
+ <form>
97
+ <h1>Ontology API</h1>
98
+ <p>Sign in to the app in this browser, or paste a bearer token or an API key (ssk_…).</p>
99
+ <label for="token">Token or API key</label>
100
+ <input id="token" type="password" autocomplete="off" spellcheck="false" required />
101
+ <button type="submit">Open the reference</button>
102
+ <p role="alert"></p>
103
+ </form>
104
+ </main>
105
+ <div id="app"></div>
106
+ <script
107
+ nonce="${nonce}"
108
+ src="${SCALAR_SCRIPT.src}"
109
+ integrity="${SCALAR_SCRIPT.integrity}"
110
+ crossorigin="anonymous"
111
+ ></script>
112
+ <script nonce="${nonce}">
113
+ (() => {
114
+ const settings = ${settings};
115
+ const signIn = document.getElementById("sign-in");
116
+ const form = signIn.querySelector("form");
117
+ const input = document.getElementById("token");
118
+ const alert = signIn.querySelector("[role=alert]");
119
+
120
+ async function load(token) {
121
+ const response = await fetch(settings.openapi, {
122
+ credentials: "same-origin",
123
+ headers: token ? { authorization: "Bearer " + token } : {},
124
+ });
125
+
126
+ if (response.ok) {
127
+ return { spec: await response.json() };
128
+ }
129
+
130
+ const body = await response.json().catch(() => ({}));
131
+
132
+ return { status: response.status, detail: body.detail };
133
+ }
134
+
135
+ function show(spec, token) {
136
+ signIn.hidden = true;
137
+ window.Scalar.createApiReference(document.getElementById("app"), {
138
+ ...settings.configuration,
139
+ content: spec,
140
+ authentication: {
141
+ preferredSecurityScheme: "bearer",
142
+ ...(token ? { securitySchemes: { bearer: { token } } } : {}),
143
+ },
144
+ });
145
+ }
146
+
147
+ async function open(token) {
148
+ let result;
149
+
150
+ try {
151
+ result = await load(token);
152
+ } catch {
153
+ result = { status: 0 };
154
+ }
155
+
156
+ if (result.spec !== undefined) {
157
+ if (token) {
158
+ sessionStorage.setItem(settings.storageKey, token);
159
+ }
160
+ show(result.spec, token);
161
+
162
+ return;
163
+ }
164
+
165
+ sessionStorage.removeItem(settings.storageKey);
166
+ signIn.hidden = false;
167
+ alert.textContent =
168
+ !token && result.status === 401
169
+ ? ""
170
+ : (result.detail ?? "The reference could not be loaded. Try again shortly.");
171
+ }
172
+
173
+ form.addEventListener("submit", (event) => {
174
+ event.preventDefault();
175
+ open(input.value.trim());
176
+ });
177
+
178
+ open(sessionStorage.getItem(settings.storageKey) ?? "");
179
+ })();
180
+ </script>
181
+ </body>
182
+ </html>
183
+ `;
184
+ }
185
+ /**
186
+ * The API reference at `/api/ontology/docs`: Scalar over the service's own `openapi.json`. The page
187
+ * is public and holds no part of the ontology: it loads the document as the reader, with the app's
188
+ * session cookie when they are signed in to the app, or with a token they paste.
189
+ */
190
+ export const DocsRoute = HttpRouter.add("GET", DOCS_PATH, Effect.sync(() => {
191
+ const nonce = crypto.randomUUID().replaceAll("-", "");
192
+ return HttpServerResponse.html(page(nonce)).pipe(HttpServerResponse.setHeaders({
193
+ "content-security-policy": contentSecurityPolicy(nonce),
194
+ "referrer-policy": "no-referrer",
195
+ "x-content-type-options": "nosniff",
196
+ }));
197
+ }));
@@ -51,6 +51,11 @@ export declare function unauthorized(requestId: string, detail: string): ErrorRe
51
51
  * The detail is written for the person, since the app shows it.
52
52
  */
53
53
  export declare function notInvited(requestId: string): ErrorResponse;
54
+ /**
55
+ * A request that changes something came with the session cookie but not from the app's own pages.
56
+ * The cookie rides along with any request to the app's domain, so only its origin can vouch for it.
57
+ */
58
+ export declare function crossOriginSession(requestId: string): ErrorResponse;
54
59
  export declare function unavailable(requestId: string): ErrorResponse;
55
60
  export declare function internalError(requestId: string): ErrorResponse;
56
61
  export declare function routeNotFound(requestId: string): ErrorResponse;
@@ -67,6 +67,20 @@ export function notInvited(requestId) {
67
67
  body: { error: "not_invited", detail: "Ask your administrator for an invitation.", requestId },
68
68
  };
69
69
  }
70
+ /**
71
+ * A request that changes something came with the session cookie but not from the app's own pages.
72
+ * The cookie rides along with any request to the app's domain, so only its origin can vouch for it.
73
+ */
74
+ export function crossOriginSession(requestId) {
75
+ return {
76
+ status: 403,
77
+ body: {
78
+ error: "forbidden",
79
+ detail: "The session cookie only authenticates requests from the app's own pages; send a bearer token.",
80
+ requestId,
81
+ },
82
+ };
83
+ }
70
84
  export function unavailable(requestId) {
71
85
  return {
72
86
  status: 503,
@@ -1,6 +1,10 @@
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 { editsFromWire, generatedResultFromWire, recordFromWire } from "../authoring-records.js";
5
+ import { nameToWire } from "../authoring-names.js";
6
+ import { principalFromWire } from "../authoring.js";
7
+ import { toCamelCase } from "../names.js";
4
8
  import { PermissionDenied } from "../errors.js";
5
9
  import { missingPermission, permissionOf, READ_VERB } from "../permissions.js";
6
10
  import { EMAIL_PATTERN } from "../property.js";
@@ -141,26 +145,31 @@ function readEnvelope(request) {
141
145
  * The configured hook as `runAction` runs it after the commit. A failure is logged with the request
142
146
  * id here, so the action's caller still gets the committed result.
143
147
  */
144
- function afterCommitOf(hook, request) {
145
- return (committed) => Effect.tryPromise(async () => hook({
146
- entity: committed.entity,
147
- action: committed.action,
148
- principal: committed.principal,
149
- input: request.input,
150
- result: committed.result,
151
- edits: committed.edits,
152
- requestId: request.requestId,
153
- })).pipe(Effect.catchCause((cause) => Effect.logError("ontology.after_commit.failed", cause).pipe(Effect.annotateLogs({
154
- requestId: request.requestId,
155
- action: `${committed.entity}.${committed.action}`,
156
- }))));
148
+ function afterCommitOf(hook, request, ontology) {
149
+ return (committed) => {
150
+ const definition = ontology.config.entities.find((entity) => nameToWire(entity.name) === committed.entity)?.definition;
151
+ return Effect.tryPromise(async () => hook({
152
+ entity: toCamelCase(committed.entity),
153
+ action: toCamelCase(committed.action),
154
+ principal: principalFromWire(committed.principal),
155
+ input: request.generated ? recordFromWire(committed.input, definition) : committed.input,
156
+ result: request.generated
157
+ ? generatedResultFromWire(committed.result, committed.action, definition)
158
+ : committed.result,
159
+ edits: editsFromWire(committed.edits, ontology.config.entities),
160
+ requestId: request.requestId,
161
+ })).pipe(Effect.catchCause((cause) => Effect.logError("ontology.after_commit.failed", cause).pipe(Effect.annotateLogs({
162
+ requestId: request.requestId,
163
+ action: `${committed.entity}.${committed.action}`,
164
+ }))));
165
+ };
157
166
  }
158
167
  /** The deployment's OIDC token: Vercel sets it on each request, and in the environment of builds. */
159
168
  function identityToken(request) {
160
169
  const header = request.headers[OIDC_TOKEN_HEADER];
161
170
  return typeof header === "string" && header !== "" ? header : process.env.VERCEL_OIDC_TOKEN;
162
171
  }
163
- function runAction(planned, { name }, hook, connections) {
172
+ function runAction(planned, { name, generated }, ontology, hook, connections) {
164
173
  return ({ request }) => Effect.gen(function* () {
165
174
  const backend = yield* OntologyBackend;
166
175
  const principal = yield* CurrentPrincipal;
@@ -180,7 +189,7 @@ function runAction(planned, { name }, hook, connections) {
180
189
  }),
181
190
  ...(hook === undefined
182
191
  ? {}
183
- : { afterCommit: afterCommitOf(hook, { input: envelope.input, requestId }) }),
192
+ : { afterCommit: afterCommitOf(hook, { requestId, generated }, ontology) }),
184
193
  });
185
194
  const { issues } = outcome.validation;
186
195
  if (!outcome.validation.ok && !validateOnly) {
@@ -214,7 +223,7 @@ function entityHandlers(built, planned, hooks, targets, connections) {
214
223
  registered = registered.handle(`related.${traversal.name}`, (request) => traverse(planned, traversal, request, targets).pipe(Effect.provideService(OntologyBackend, backend)));
215
224
  }
216
225
  for (const action of planned.actions) {
217
- const handler = runAction(planned, action, hooks.get(`${planned.name}.${action.name}`), connections);
226
+ const handler = runAction(planned, action, built.plan.ontology, hooks.get(`${planned.name}.${action.name}`), connections);
218
227
  registered = registered.handleRaw(`actions.${action.name}`, (request) => handler(request).pipe(Effect.provideService(OntologyBackend, backend)));
219
228
  }
220
229
  return registered;
@@ -1,12 +1,14 @@
1
1
  import { Cause, Context, Effect, Layer } from "effect";
2
2
  import { HttpRouter, HttpServer, HttpServerError } from "effect/unstable/http";
3
3
  import { HttpApiBuilder } from "effect/unstable/httpapi";
4
+ import { nameToWire } from "../authoring-names.js";
4
5
  import { sqlServiceRuntime } from "../runtime/service-binding.js";
5
6
  import { ErrorResponsesLive, buildApi } from "./api.js";
6
- import { authenticationLayer, tokenVerifier } from "./authentication.js";
7
+ import { authenticationLayer, neonAuthSessionExchange, tokenVerifier } from "./authentication.js";
7
8
  import { OntologyServiceConfigError, } from "./config.js";
8
9
  import { RequestId, failureResponse, internalError, errorBodyForWire, routeNotFound, toServerResponse, } from "./errors.js";
9
10
  import { deployedConnections, localConnections } from "./connections.js";
11
+ import { DocsRoute } from "./docs.js";
10
12
  import { handlersLayer } from "./handlers.js";
11
13
  import { planApi } from "./plan.js";
12
14
  /** The header every response carries: the hash of the manifest the service serves. */
@@ -15,13 +17,19 @@ export const ONTOLOGY_HASH_HEADER = "x-ontology-hash";
15
17
  export const REQUEST_ID_HEADER = "x-request-id";
16
18
  function afterCommitHooks(plan, hooks = {}) {
17
19
  const actions = new Set(plan.entities.flatMap(({ name, actions: planned }) => planned.map((action) => `${name}.${action.name}`)));
18
- const unknown = Object.keys(hooks).filter((key) => !actions.has(key));
20
+ const entries = Object.entries(hooks).map(([key, hook]) => [key.split(".").map(nameToWire).join("."), hook]);
21
+ const unknown = Object.keys(hooks).filter((key) => !actions.has(key.split(".").map(nameToWire).join(".")));
19
22
  if (unknown.length > 0) {
20
23
  throw new OntologyServiceConfigError({
21
24
  message: `afterCommit names actions the ontology does not have: ${unknown.join(", ")}.`,
22
25
  });
23
26
  }
24
- return new Map(Object.entries(hooks));
27
+ if (new Set(entries.map(([key]) => key)).size !== entries.length) {
28
+ throw new OntologyServiceConfigError({
29
+ message: "afterCommit names collide after snake_case conversion.",
30
+ });
31
+ }
32
+ return new Map(entries);
25
33
  }
26
34
  /**
27
35
  * Requests that match no route, and defects that escape a route's own error handling, still get a
@@ -55,13 +63,14 @@ export function createOntologyService(ontology, config) {
55
63
  const built = buildApi(plan);
56
64
  const hooks = afterCommitHooks(plan, config.afterCommit);
57
65
  const verifyToken = tokenVerifier(config);
66
+ const exchangeSession = config.neonAuth === undefined ? undefined : neonAuthSessionExchange(config.neonAuth);
58
67
  const backend = runtime.connect({ ontology, databaseUrl: config.databaseUrl });
59
68
  const connections = config.connections ??
60
69
  (config.localConnections === undefined
61
70
  ? deployedConnections(config)
62
71
  : localConnections(config.localConnections));
63
- const routes = HttpApiBuilder.layer(built.api).pipe(Layer.provide(handlersLayer(built, hooks, connections)), Layer.provide([authenticationLayer(verifyToken), ErrorResponsesLive]), Layer.provide(backend));
64
- 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));
65
74
  const web = HttpRouter.toWebHandler(application, { disableLogger: true });
66
75
  const hash = ontology.manifest.hash;
67
76
  return {
package/dist/user.d.ts CHANGED
@@ -13,8 +13,8 @@ export declare const User: import("./entity.js").DraftEntity<"user", {
13
13
  readonly email: import("effect/Schema").optionalKey<import("./property.js").Property<string, false, true>>;
14
14
  readonly kind: import("./property.js").Property<"member" | "service", false, false>;
15
15
  readonly active: import("./property.js").Property<boolean, true, false>;
16
- readonly identity_provider: import("effect/Schema").optionalKey<import("./property.js").Property<string, false, false>>;
17
- readonly identity_subject: import("effect/Schema").optionalKey<import("./property.js").Property<string, false, false>>;
18
- readonly last_sign_in_at: import("effect/Schema").optionalKey<import("./property.js").Property<string, false, false>>;
16
+ readonly identityProvider: import("effect/Schema").optionalKey<import("./property.js").Property<string, false, false>>;
17
+ readonly identitySubject: import("effect/Schema").optionalKey<import("./property.js").Property<string, false, false>>;
18
+ readonly lastSignInAt: import("effect/Schema").optionalKey<import("./property.js").Property<string, false, false>>;
19
19
  }, import("./entity.js").Relationships>;
20
20
  export type User = typeof User.Type;
package/dist/user.js CHANGED
@@ -41,13 +41,13 @@ export const User = entity("user", {
41
41
  managed: true,
42
42
  description: "Inactive users cannot sign in or run actions.",
43
43
  }),
44
- identity_provider: p.text({
44
+ identityProvider: p.text({
45
45
  renamedFrom: "identityProvider",
46
46
  optional: true,
47
47
  managed: true,
48
48
  description: "Who signs the member in, for example neon-auth. Empty for services.",
49
49
  }),
50
- identity_subject: p.text({
50
+ identitySubject: p.text({
51
51
  renamedFrom: "identitySubject",
52
52
  optional: true,
53
53
  managed: true,
@@ -55,7 +55,7 @@ export const User = entity("user", {
55
55
  }),
56
56
  // Optional so that a table created before it gains the column without a backfill: a member
57
57
  // who has not signed in since simply has none.
58
- last_sign_in_at: p.timestamp({
58
+ lastSignInAt: p.timestamp({
59
59
  renamedFrom: "lastSignInAt",
60
60
  optional: true,
61
61
  managed: true,
@@ -63,5 +63,5 @@ export const User = entity("user", {
63
63
  description: "When the member last signed in to the app. Empty until they do.",
64
64
  }),
65
65
  },
66
- indexes: [{ properties: ["identity_provider", "identity_subject"], unique: true }],
66
+ indexes: [{ properties: ["identityProvider", "identitySubject"], unique: true }],
67
67
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@segmentstream-cloud/ontology",
3
- "version": "0.7.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",