@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.
package/dist/entity.d.ts CHANGED
@@ -3,7 +3,6 @@ import type { Measures } from "./measure.js";
3
3
  import type { WarehouseSource } from "./source.js";
4
4
  import type { Actions } from "./action.js";
5
5
  import type { Conflict, NotFound } from "./errors.js";
6
- import type { SystemProperty } from "./names.js";
7
6
  import { type AnyProperty } from "./property.js";
8
7
  import type { AnyRelationship, ManyRelationship, OneRelationship } from "./relationship.js";
9
8
  import { OntologyReads, OntologyWrites, type FindOptions, type GetOptions, type UpsertResult } from "./runtime.js";
@@ -20,11 +19,11 @@ export interface SystemFields {
20
19
  /** A UUID. */
21
20
  readonly id: string;
22
21
  /** ISO 8601 timestamps. */
23
- readonly created_at: string;
24
- readonly updated_at: string;
22
+ readonly createdAt: string;
23
+ readonly updatedAt: string;
25
24
  /** The `user` who created or last updated the record; `null` when the platform wrote it. */
26
- readonly created_by: string | null;
27
- readonly updated_by: string | null;
25
+ readonly createdBy: string | null;
26
+ readonly updatedBy: string | null;
28
27
  }
29
28
  /** A record id, or a record. */
30
29
  export type RecordRef = string | {
@@ -58,9 +57,9 @@ type UniqueKeys<P> = {
58
57
  } ? K : never;
59
58
  }[keyof P] & string;
60
59
  type RelationshipIds<R> = {
61
- readonly [K in OneKeys<R> as `${K}_id`]: R[K] extends OneRelationship<true> ? string | null : string;
60
+ readonly [K in OneKeys<R> as `${K}Id`]: R[K] extends OneRelationship<true> ? string | null : string;
62
61
  };
63
- /** A record of the entity: system fields, declared properties, and `<relationship>_id` per `rel.one`. */
62
+ /** A record of the entity: system fields, declared properties, and `<relationship>Id` per `rel.one`. */
64
63
  export type EntityRecord<P, R> = Simplify<SystemFields & {
65
64
  readonly [K in keyof P]: ValueOf<P[K]>;
66
65
  } & RelationshipIds<R>>;
@@ -88,7 +87,7 @@ export type UpdateValues<P, R> = Simplify<{
88
87
  readonly [K in ManyKeys<R>]?: ReadonlyArray<string>;
89
88
  }>;
90
89
  /** Fields an index may cover: declared properties, system properties and `rel.one` relationships. */
91
- export type IndexField<P, R> = (keyof P & string) | SystemProperty | OneKeys<R>;
90
+ export type IndexField<P, R> = (keyof P & string) | keyof SystemFields | OneKeys<R>;
92
91
  /** An index: the covered fields in order, or the fields with options. */
93
92
  export type IndexDefinition<Field extends string = string> = ReadonlyArray<Field> | {
94
93
  readonly properties: ReadonlyArray<Field>;
package/dist/entity.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { Effect } from "effect";
2
2
  import { p } from "./property.js";
3
+ import { recordFromWire, valuesToWire, findToWire } from "./authoring-records.js";
4
+ import { toSnakeCase } from "./names.js";
3
5
  import { OntologyReads, OntologyWrites, } from "./runtime.js";
4
6
  export const EntityTypeId = Symbol.for("@segmentstream-cloud/ontology/Entity");
5
7
  function idOf(record) {
@@ -25,17 +27,38 @@ export function entity(name, definition) {
25
27
  relationships: definition.relationships ?? {},
26
28
  ref: p.ref(name),
27
29
  get: (record, options) => withReads((runtime) => runtime.get({
28
- entity: name,
30
+ entity: toSnakeCase(name),
29
31
  id: idOf(record),
30
32
  ...(options?.forUpdate === true ? { forUpdate: true } : {}),
33
+ })).pipe(Effect.map((record) => recordFromWire(record, definition))),
34
+ find: (options) => withReads((runtime) => runtime.find({ entity: toSnakeCase(name), options: findToWire(options ?? {}, definition) })).pipe(Effect.map((records) => records.map((record) => recordFromWire(record, definition)))),
35
+ create: (values) => withWrites((runtime) => runtime.create({ entity: toSnakeCase(name), values: valuesToWire(values, definition) })).pipe(Effect.map((record) => recordFromWire(record, definition))),
36
+ update: (record, values) => withWrites((runtime) => runtime.update({
37
+ entity: toSnakeCase(name),
38
+ id: idOf(record),
39
+ values: valuesToWire(values, definition),
40
+ })).pipe(Effect.map((record) => recordFromWire(record, definition))),
41
+ upsertBy: (property, values) => withWrites((runtime) => runtime.upsertBy({
42
+ entity: toSnakeCase(name),
43
+ property: toSnakeCase(property),
44
+ values: valuesToWire(values, definition),
45
+ })).pipe(Effect.map((result) => ({
46
+ ...result,
47
+ record: recordFromWire(result.record, definition),
48
+ }))),
49
+ delete: (record) => withWrites((runtime) => runtime.delete({ entity: toSnakeCase(name), id: idOf(record) })),
50
+ link: (relationship, record, target) => withWrites((runtime) => runtime.link({
51
+ entity: toSnakeCase(name),
52
+ relationship: toSnakeCase(relationship),
53
+ id: idOf(record),
54
+ targetId: idOf(target),
55
+ })),
56
+ unlink: (relationship, record, target) => withWrites((runtime) => runtime.unlink({
57
+ entity: toSnakeCase(name),
58
+ relationship: toSnakeCase(relationship),
59
+ id: idOf(record),
60
+ targetId: idOf(target),
31
61
  })),
32
- find: (options) => withReads((runtime) => runtime.find({ entity: name, options: options ?? {} })),
33
- create: (values) => withWrites((runtime) => runtime.create({ entity: name, values })),
34
- update: (record, values) => withWrites((runtime) => runtime.update({ entity: name, id: idOf(record), values })),
35
- upsertBy: (property, values) => withWrites((runtime) => runtime.upsertBy({ entity: name, property, values })),
36
- delete: (record) => withWrites((runtime) => runtime.delete({ entity: name, id: idOf(record) })),
37
- link: (relationship, record, target) => withWrites((runtime) => runtime.link({ entity: name, relationship, id: idOf(record), targetId: idOf(target) })),
38
- unlink: (relationship, record, target) => withWrites((runtime) => runtime.unlink({ entity: name, relationship, id: idOf(record), targetId: idOf(target) })),
39
62
  };
40
63
  // Every form of the entity shares `definition`, which is how defineOntology recognises them.
41
64
  const self = { ...handle, actions: {} };
@@ -1,4 +1,4 @@
1
- import { SNAKE_CASE_NAME, isSystemProperty } from "../names.js";
1
+ import { SNAKE_CASE_NAME, isSystemProperty, toSnakeCase } from "../names.js";
2
2
  import { CATALOG_TABLE_DDL, MAX_IDENTIFIER_LENGTH, USER_TABLE, addConstraintStatement, columnDefinition, constraintDefinition, createEnumStatement, createIndexStatement, createTableStatement, defaultExpression, entityStorage, enumTypeName, fieldColumnName, joinTable, propertyCheck, propertyColumn, propertyEnum, quoteIdentifier, quoteLiteral, relationshipColumn, RUNTIME_TABLES_DDL, relationshipForeignKey, SYSTEM_COLUMNS, tableName, } from "./storage.js";
3
3
  const GLOBAL_KINDS = new Set(["table", "type", "index"]);
4
4
  function step(input) {
@@ -296,9 +296,10 @@ function matchProperties(current, target) {
296
296
  const addedProperties = [];
297
297
  for (const property of target.properties) {
298
298
  const sameName = byName.get(property.name);
299
- const previous = property.renamedFrom !== undefined && !claimed.has(property.renamedFrom)
300
- ? byName.get(property.renamedFrom)
301
- : undefined;
299
+ const source = property.renamedFrom === undefined
300
+ ? undefined
301
+ : (byName.get(property.renamedFrom) ?? byName.get(toSnakeCase(property.renamedFrom)));
302
+ const previous = source !== undefined && !claimed.has(source.name) ? source : undefined;
302
303
  const match = sameName ?? previous;
303
304
  if (match === undefined) {
304
305
  addedProperties.push(property);
package/dist/names.d.ts CHANGED
@@ -1,12 +1,15 @@
1
1
  /** Ontology names: lowercase snake_case. */
2
2
  export declare const SNAKE_CASE_NAME: RegExp;
3
+ /** Convert each character once, including acronym boundaries such as HTTPClient. */
4
+ export declare function toSnakeCase(name: string): string;
5
+ export declare function toCamelCase(name: string): string;
3
6
  /** Historical property names accepted only as explicit rename sources. */
4
7
  export declare const LEGACY_MEMBER_NAME: RegExp;
5
8
  /** Stable entity identities, for example `ent_01J9Q4M8ZK`. */
6
9
  export declare const ENTITY_ID: RegExp;
7
10
  /** Properties every entity has. They are never declared and cannot be redeclared. */
8
11
  export declare const SYSTEM_PROPERTIES: readonly ["id", "created_at", "updated_at", "created_by", "updated_by"];
9
- export type SystemProperty = (typeof SYSTEM_PROPERTIES)[number];
12
+ export type SystemProperty = "id" | "createdAt" | "updatedAt" | "createdBy" | "updatedBy";
10
13
  /** Actions the platform generates for every entity; authored actions cannot use these names. */
11
14
  export declare const GENERATED_ACTIONS: readonly ["create", "update", "delete", "link", "unlink"];
12
15
  /** Prefix of the generated `upsert_by_<property>` actions. */
@@ -18,7 +21,7 @@ export declare const HISTORY_SUFFIX = "_history";
18
21
  /** The combinators of the query language, which take the place of field names in a `where`. */
19
22
  export declare const RESERVED_MEMBER_NAMES: readonly ["and", "or", "not"];
20
23
  /** Routes of the ontology service under `/api/ontology/`, which an entity's routes would shadow. */
21
- 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"];
22
25
  /** `/api/ontology/<entity>/{id}/history`, which a relationship's traversal would shadow. */
23
26
  export declare const RESERVED_RELATIONSHIP_NAMES: readonly ["history"];
24
27
  export declare function isSystemProperty(name: string): boolean;
package/dist/names.js CHANGED
@@ -1,5 +1,31 @@
1
1
  /** Ontology names: lowercase snake_case. */
2
2
  export const SNAKE_CASE_NAME = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/;
3
+ /** TypeScript names are converted only at the ontology's serialization boundary. */
4
+ function isUppercase(letter) {
5
+ return letter !== undefined && letter >= "A" && letter <= "Z";
6
+ }
7
+ function isLowercase(letter) {
8
+ return letter !== undefined && letter >= "a" && letter <= "z";
9
+ }
10
+ /** Convert each character once, including acronym boundaries such as HTTPClient. */
11
+ export function toSnakeCase(name) {
12
+ const characters = [];
13
+ for (let index = 0; index < name.length; index++) {
14
+ const current = name[index];
15
+ const previous = name[index - 1];
16
+ const next = name[index + 1];
17
+ const followsWord = isLowercase(previous) || (previous !== undefined && previous >= "0" && previous <= "9");
18
+ const endsAcronym = isUppercase(previous) && isLowercase(next);
19
+ if (isUppercase(current) && (followsWord || endsAcronym)) {
20
+ characters.push("_");
21
+ }
22
+ characters.push(current);
23
+ }
24
+ return characters.join("").toLowerCase();
25
+ }
26
+ export function toCamelCase(name) {
27
+ return name.replace(/_([a-z0-9])/g, (_, letter) => letter.toUpperCase());
28
+ }
3
29
  /** Historical property names accepted only as explicit rename sources. */
4
30
  export const LEGACY_MEMBER_NAME = /^(?:[a-z][a-zA-Z0-9]*|[a-z][a-z0-9]*(?:_[a-z0-9]+)*)$/;
5
31
  /** Stable entity identities, for example `ent_01J9Q4M8ZK`. */
@@ -28,6 +54,7 @@ export const RESERVED_ENTITY_NAMES = [
28
54
  "status",
29
55
  "describe",
30
56
  "openapi",
57
+ "docs",
31
58
  "mcp",
32
59
  "me",
33
60
  ];
@@ -1,4 +1,4 @@
1
- import { GENERATED_UPSERT_PREFIX } from "./names.js";
1
+ import { GENERATED_UPSERT_PREFIX, toSnakeCase } from "./names.js";
2
2
  /**
3
3
  * Permissions are `<entity>:<verb>` strings. The verbs are `read`, the generated `create`, `update`,
4
4
  * `delete`, `link` and `unlink`, and the snake_case name of an authored action. A role may use `*` for
@@ -12,7 +12,7 @@ export const READ_VERB = "read";
12
12
  /** What the built-in `member` role holds when `defineOntology` does not say: reading everything. */
13
13
  export const DEFAULT_MEMBER_PERMISSIONS = [`${WILDCARD}:${READ_VERB}`];
14
14
  /** `<entity>:<verb>` with lowercase snake_case entities and lowercase snake_case verbs, or `*` for either. */
15
- const PERMISSION = /^(\*|[a-z][a-z0-9]*(?:_[a-z0-9]+)*):(\*|[a-z][a-z0-9]*(?:_[a-z0-9]+)*)$/;
15
+ const PERMISSION = /^(\*|[a-z][a-zA-Z0-9]*(?:_[a-z0-9]+)*):(\*|[a-z][a-zA-Z0-9]*(?:_[a-z0-9]+)*)$/;
16
16
  /** The entity and verb of a permission, or `undefined` when the value is not one. */
17
17
  export function parsePermission(value) {
18
18
  if (typeof value !== "string") {
@@ -32,8 +32,8 @@ export function grants(granted, required) {
32
32
  if (held === undefined || wanted === undefined) {
33
33
  return false;
34
34
  }
35
- return ((held.entity === WILDCARD || held.entity === wanted.entity) &&
36
- (held.verb === WILDCARD || held.verb === wanted.verb));
35
+ return ((held.entity === WILDCARD || toSnakeCase(held.entity) === toSnakeCase(wanted.entity)) &&
36
+ (held.verb === WILDCARD || toSnakeCase(held.verb) === toSnakeCase(wanted.verb)));
37
37
  }
38
38
  /**
39
39
  * Whether the principal may do what `required` names: one of its permissions equals it, or covers it
@@ -51,7 +51,7 @@ export function missingPermission(required, principal) {
51
51
  * create or update and so requires both `<entity>:create` and `<entity>:update`.
52
52
  */
53
53
  export function generatedActionPermissions(entity, action) {
54
- return action.startsWith(GENERATED_UPSERT_PREFIX)
54
+ return toSnakeCase(action).startsWith(GENERATED_UPSERT_PREFIX)
55
55
  ? [permissionOf(entity, "create"), permissionOf(entity, "update")]
56
56
  : [permissionOf(entity, action)];
57
57
  }
package/dist/property.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Schema } from "effect";
2
2
  import { humanize } from "./names.js";
3
+ import { nameToWire } from "./authoring-names.js";
3
4
  /** The closed set of storage types. Each has a known column, JSON Schema, filters and rendering. */
4
5
  export const PROPERTY_TYPES = [
5
6
  "text",
@@ -171,7 +172,7 @@ function ref(target, options) {
171
172
  ...(settings.label !== undefined ? { title: settings.label } : {}),
172
173
  description: settings.description ?? `The id of a ${entityName} record.`,
173
174
  format: "uuid",
174
- [REFERENCE_ANNOTATION]: entityName,
175
+ [REFERENCE_ANNOTATION]: nameToWire(entityName),
175
176
  }).check(Schema.isPattern(UUID));
176
177
  return (settings.optional === true ? Schema.optionalKey(schema) : schema);
177
178
  }
@@ -5,7 +5,6 @@ import { generatedActionPermissions } from "../permissions.js";
5
5
  import { p, REFERENCE_ANNOTATION } from "../property.js";
6
6
  import { isCalendarDate, isInstant } from "../query.js";
7
7
  import { OntologyWrites } from "../runtime.js";
8
- import { User } from "../user.js";
9
8
  import { entityModel, ontologyModel, propertyValueSchema, } from "./model.js";
10
9
  /** Generated actions, built once per ontology and entity. */
11
10
  const builtActions = new WeakMap();
@@ -98,7 +97,7 @@ export function generatedActions(ontology, entityName) {
98
97
  }
99
98
  function buildGeneratedActions(ontology, entityName) {
100
99
  const entity = entityModel(ontologyModel(ontology), entityName);
101
- if (entity.resolved.entity.definition === User.definition) {
100
+ if (entity.resolved.builtIn) {
102
101
  return {};
103
102
  }
104
103
  const label = entity.label.toLowerCase();
@@ -6,7 +6,7 @@ import { SqlClient } from "effect/unstable/sql";
6
6
  import { isSqlError } from "effect/unstable/sql/SqlError";
7
7
  import { entity, } from "../entity.js";
8
8
  import { API_KEYS_TABLE, USER_ROLES_TABLE, USER_TABLE } from "../migrate/storage.js";
9
- import { MEMBER_ROLE } from "../names.js";
9
+ import { MEMBER_ROLE, toSnakeCase } from "../names.js";
10
10
  import { buildOntology } from "../ontology.js";
11
11
  import { permissionsOf } from "../permissions.js";
12
12
  import { EMAIL_PATTERN } from "../property.js";
@@ -318,7 +318,7 @@ function deployedUser(manifest) {
318
318
  return User;
319
319
  }
320
320
  const names = new Set(deployed.properties.map((property) => property.name));
321
- const kept = Object.entries(User.definition.properties).filter(([name, property]) => names.has(name) ||
321
+ const kept = Object.entries(User.definition.properties).filter(([name, property]) => names.has(toSnakeCase(name)) ||
322
322
  (propertyDefinition(property)?.renamedFrom !== undefined &&
323
323
  names.has(propertyDefinition(property).renamedFrom)));
324
324
  if (kept.length === Object.keys(User.definition.properties).length) {
@@ -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":
@@ -48,7 +48,7 @@ export interface ActionCommitted {
48
48
  readonly entity: string;
49
49
  readonly action: string;
50
50
  readonly principal: Principal;
51
- /** The input as the caller sent it. */
51
+ /** The decoded input, using the authored TypeScript schema. */
52
52
  readonly input: unknown;
53
53
  readonly result: unknown;
54
54
  readonly edits: ReadonlyArray<unknown>;
@@ -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>;