@segmentstream-cloud/ontology 0.10.0 → 0.12.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.
Files changed (73) hide show
  1. package/README.md +6 -0
  2. package/dist/action.d.ts +19 -0
  3. package/dist/action.js +1 -0
  4. package/dist/analyze.d.ts +3 -1
  5. package/dist/analyze.js +81 -1
  6. package/dist/bin/migrate.js +3 -2
  7. package/dist/diagnostics.d.ts +3 -3
  8. package/dist/diagnostics.js +1 -0
  9. package/dist/index.d.ts +5 -3
  10. package/dist/index.js +4 -1
  11. package/dist/manifest.d.ts +48 -0
  12. package/dist/manifest.js +9 -0
  13. package/dist/migrate/apply.d.ts +8 -1
  14. package/dist/migrate/apply.js +54 -4
  15. package/dist/migrate/index.d.ts +1 -1
  16. package/dist/migrate/index.js +1 -1
  17. package/dist/migrate/model.d.ts +8 -0
  18. package/dist/migrate/plan.d.ts +6 -0
  19. package/dist/migrate/plan.js +10 -2
  20. package/dist/migrate/storage.d.ts +12 -0
  21. package/dist/migrate/storage.js +82 -0
  22. package/dist/names.d.ts +9 -1
  23. package/dist/names.js +11 -0
  24. package/dist/nitro.d.ts +13 -0
  25. package/dist/nitro.js +18 -0
  26. package/dist/replication/baseline.d.ts +19 -0
  27. package/dist/replication/baseline.js +97 -0
  28. package/dist/replication/bigquery.d.ts +23 -0
  29. package/dist/replication/bigquery.js +63 -0
  30. package/dist/replication/model.d.ts +38 -0
  31. package/dist/replication/model.js +89 -0
  32. package/dist/replication/schema.d.ts +7 -0
  33. package/dist/replication/schema.js +123 -0
  34. package/dist/replication/store.d.ts +35 -0
  35. package/dist/replication/store.js +88 -0
  36. package/dist/replication/transaction.d.ts +4 -0
  37. package/dist/replication/transaction.js +11 -0
  38. package/dist/runtime/generated-actions.d.ts +3 -0
  39. package/dist/runtime/generated-actions.js +3 -2
  40. package/dist/runtime/oauth.d.ts +9 -0
  41. package/dist/runtime/oauth.js +165 -0
  42. package/dist/runtime/pipeline.js +2 -1
  43. package/dist/runtime/principals.d.ts +5 -0
  44. package/dist/runtime/principals.js +21 -0
  45. package/dist/runtime/service-binding.js +10 -0
  46. package/dist/runtime/sql-runtime.js +2 -1
  47. package/dist/service/api.js +4 -10
  48. package/dist/service/authentication.d.ts +27 -1
  49. package/dist/service/authentication.js +48 -26
  50. package/dist/service/config.d.ts +5 -0
  51. package/dist/service/descriptions.d.ts +8 -0
  52. package/dist/service/descriptions.js +15 -0
  53. package/dist/service/errors.d.ts +5 -1
  54. package/dist/service/errors.js +6 -2
  55. package/dist/service/handlers.d.ts +3 -5
  56. package/dist/service/handlers.js +21 -204
  57. package/dist/service/mcp/server.d.ts +26 -0
  58. package/dist/service/mcp/server.js +370 -0
  59. package/dist/service/mcp/tools.d.ts +47 -0
  60. package/dist/service/mcp/tools.js +218 -0
  61. package/dist/service/oauth/protocol.d.ts +52 -0
  62. package/dist/service/oauth/protocol.js +110 -0
  63. package/dist/service/oauth/server.d.ts +27 -0
  64. package/dist/service/oauth/server.js +576 -0
  65. package/dist/service/operations.d.ts +115 -0
  66. package/dist/service/operations.js +228 -0
  67. package/dist/service/replication.d.ts +5 -0
  68. package/dist/service/replication.js +25 -0
  69. package/dist/service/runtime-contract.d.ts +90 -0
  70. package/dist/service/schemas.d.ts +8 -0
  71. package/dist/service/service.d.ts +3 -3
  72. package/dist/service/service.js +13 -6
  73. package/package.json +6 -1
@@ -0,0 +1,35 @@
1
+ import { Effect } from "effect";
2
+ import type { SqlClient } from "effect/unstable/sql";
3
+ import { type ReplicationEvent, type ReplicationSelection, type ReplicationExclusion } from "./model.js";
4
+ import { type ReplicationTarget } from "./baseline.js";
5
+ export type { ReplicationTarget } from "./baseline.js";
6
+ export declare const startReplication: (sql: SqlClient.SqlClient, input: {
7
+ readonly generation: string;
8
+ readonly exclusions?: ReadonlyArray<ReplicationExclusion>;
9
+ readonly selection?: ReadonlyArray<ReplicationSelection>;
10
+ readonly newSource?: boolean;
11
+ }) => Effect.Effect<ReplicationTarget, import("../index.js").CatalogInvalid | import("effect/unstable/sql/SqlError").SqlError, never>;
12
+ export declare const activeReplicationTarget: (sql: SqlClient.SqlClient) => Effect.Effect<ReplicationTarget, import("effect/unstable/sql/SqlError").SqlError, never>;
13
+ export declare const pauseReplication: (sql: SqlClient.SqlClient) => import("effect/unstable/sql/Statement").Statement<object>;
14
+ export declare const replicationTarget: (sql: SqlClient.SqlClient, generation: string) => Effect.Effect<ReplicationTarget, import("effect/unstable/sql/SqlError").SqlError, never>;
15
+ /** No timestamp/high-water cursor: every unacknowledged immutable event remains discoverable. */
16
+ export declare function replicationEventsSql(target: ReplicationTarget): string;
17
+ export declare const pendingReplicationEvents: (sql: SqlClient.SqlClient, target: ReplicationTarget) => Effect.Effect<{
18
+ readonly historyId: string;
19
+ readonly event: ReplicationEvent;
20
+ }[], import("effect/unstable/sql/SqlError").SqlError, never>;
21
+ /** Call only after Pub/Sub accepts the complete batch. Lost responses deliberately produce duplicates. */
22
+ export declare const acknowledgeReplication: (sql: SqlClient.SqlClient, generation: string, ids: ReadonlyArray<string>) => import("effect/unstable/sql/Statement").Statement<object>;
23
+ export declare const replayReplication: (sql: SqlClient.SqlClient, generation: string) => import("effect/unstable/sql/Statement").Statement<object>;
24
+ export declare const replicationStatus: (sql: SqlClient.SqlClient, target: ReplicationTarget) => Effect.Effect<{
25
+ total: string;
26
+ accepted: string;
27
+ verified: string;
28
+ }, import("effect/unstable/sql/SqlError").SqlError, never>;
29
+ export declare const unverifiedReplicationEvents: (sql: SqlClient.SqlClient, target: ReplicationTarget) => Effect.Effect<{
30
+ historyId: string;
31
+ eventId: string;
32
+ }[], import("effect/unstable/sql/SqlError").SqlError, never>;
33
+ export declare const verifyReplicationEvents: (sql: SqlClient.SqlClient, generation: string, ids: ReadonlyArray<string>) => import("effect/unstable/sql/Statement").Statement<object>;
34
+ /** Retry accepted events that never appeared, including dead letters and transport expiry. */
35
+ export declare const retryUnverifiedReplication: (sql: SqlClient.SqlClient, generation: string) => import("effect/unstable/sql/Statement").Statement<object>;
@@ -0,0 +1,88 @@
1
+ import { Effect } from "effect";
2
+ import { readCatalog } from "../migrate/apply.js";
3
+ import { historyTableName, quoteIdentifier as qi } from "../migrate/storage.js";
4
+ import { REPLICATION_BATCH_BYTES, REPLICATION_BATCH_SIZE, publishMessage, replicationEventId, resolveReplicationSelection, } from "./model.js";
5
+ import { createReplicationBaseline } from "./baseline.js";
6
+ export const startReplication = (sql, input) => sql.withTransaction(Effect.gen(function* () {
7
+ yield* sql.unsafe("SET LOCAL statement_timeout = '30s'");
8
+ yield* sql.unsafe("SET LOCAL lock_timeout = '5s'");
9
+ yield* sql.unsafe("SELECT id FROM _ontology_replication WHERE id FOR UPDATE");
10
+ const manifest = yield* readCatalog({ sql });
11
+ if (!manifest) {
12
+ return yield* Effect.die(new Error("The ontology has not been migrated."));
13
+ }
14
+ const selection = input.selection ??
15
+ (yield* Effect.sync(() => resolveReplicationSelection(manifest, input.exclusions)));
16
+ return yield* createReplicationBaseline(sql, { ...input, manifest, selection });
17
+ }));
18
+ export const activeReplicationTarget = (sql) => sql
19
+ .unsafe(`SELECT t.generation::text, t.source_id::text, t.from_revision::text, t.manifest, t.selection
20
+ FROM _ontology_replication_targets t JOIN _ontology_replication r ON r.active_generation = t.generation
21
+ WHERE r.id`)
22
+ .pipe(Effect.map((rows) => rows[0]));
23
+ export const pauseReplication = (sql) => sql.unsafe("UPDATE _ontology_replication SET enabled = false WHERE id");
24
+ export const replicationTarget = (sql, generation) => sql
25
+ .unsafe(`SELECT generation::text, source_id::text, from_revision::text, manifest, selection
26
+ FROM _ontology_replication_targets WHERE generation = $1::uuid`, [generation])
27
+ .pipe(Effect.map((rows) => rows[0]));
28
+ /** No timestamp/high-water cursor: every unacknowledged immutable event remains discoverable. */
29
+ export function replicationEventsSql(target) {
30
+ const histories = target.selection.map((item) => {
31
+ const entity = target.manifest.entities.find((e) => e.id === item.entityId);
32
+ return `SELECT id, replication AS event FROM ${qi(historyTableName(entity.name))}
33
+ WHERE replication IS NOT NULL AND replication->>'source_id' = $2
34
+ AND (replication->>'revision')::bigint > $3::bigint`;
35
+ });
36
+ const baseline = `SELECT id, event FROM _ontology_replication_baselines WHERE generation = $1::uuid
37
+ AND $2::text IS NOT NULL AND $3::bigint IS NOT NULL`;
38
+ return [baseline, ...histories].join(" UNION ALL ");
39
+ }
40
+ export const pendingReplicationEvents = (sql, target) => sql
41
+ .unsafe(`SELECT e.id::text, e.event FROM (${replicationEventsSql(target)}) e
42
+ WHERE NOT EXISTS (SELECT 1 FROM _ontology_replication_receipts r
43
+ WHERE r.generation = $1::uuid AND r.event_id = e.id)
44
+ ORDER BY (event->>'revision')::bigint, id LIMIT ${REPLICATION_BATCH_SIZE}`, [target.generation, target.source_id, target.from_revision])
45
+ .pipe(Effect.map((rows) => {
46
+ let size = 32;
47
+ const result = [];
48
+ for (const row of rows) {
49
+ const event = {
50
+ ...row.event,
51
+ generation: target.generation,
52
+ event_id: replicationEventId(target.source_id, row.id, target.generation),
53
+ };
54
+ const bytes = Buffer.byteLength(JSON.stringify(publishMessage(event))) + 1;
55
+ if (bytes + 32 > REPLICATION_BATCH_BYTES) {
56
+ throw new Error("A replication event exceeds the publish request limit.");
57
+ }
58
+ if (size + bytes > REPLICATION_BATCH_BYTES) {
59
+ break;
60
+ }
61
+ size += bytes;
62
+ result.push({ historyId: row.id, event });
63
+ }
64
+ return result;
65
+ }));
66
+ /** Call only after Pub/Sub accepts the complete batch. Lost responses deliberately produce duplicates. */
67
+ export const acknowledgeReplication = (sql, generation, ids) => sql.unsafe(`INSERT INTO _ontology_replication_receipts (generation, event_id)
68
+ SELECT $1::uuid, value::uuid FROM jsonb_array_elements_text($2::jsonb) ON CONFLICT DO NOTHING`, [generation, JSON.stringify(ids)]);
69
+ export const replayReplication = (sql, generation) => sql.unsafe("DELETE FROM _ontology_replication_receipts WHERE generation = $1::uuid", [
70
+ generation,
71
+ ]);
72
+ export const replicationStatus = (sql, target) => sql
73
+ .unsafe(`SELECT count(*)::text AS total, count(r.event_id)::text AS accepted, count(r.verified_at)::text AS verified
74
+ FROM (${replicationEventsSql(target)}) e
75
+ LEFT JOIN _ontology_replication_receipts r ON r.generation = $1::uuid AND r.event_id = e.id`, [target.generation, target.source_id, target.from_revision])
76
+ .pipe(Effect.map((rows) => rows[0]));
77
+ export const unverifiedReplicationEvents = (sql, target) => sql
78
+ .unsafe(`SELECT event_id::text AS id FROM _ontology_replication_receipts
79
+ WHERE generation = $1::uuid AND verified_at IS NULL ORDER BY accepted_at, event_id LIMIT 1000`, [target.generation])
80
+ .pipe(Effect.map((rows) => rows.map(({ id }) => ({
81
+ historyId: id,
82
+ eventId: replicationEventId(target.source_id, id, target.generation),
83
+ }))));
84
+ export const verifyReplicationEvents = (sql, generation, ids) => sql.unsafe(`UPDATE _ontology_replication_receipts SET verified_at = now()
85
+ WHERE generation = $1::uuid AND event_id IN (SELECT value::uuid FROM jsonb_array_elements_text($2::jsonb))`, [generation, JSON.stringify(ids)]);
86
+ /** Retry accepted events that never appeared, including dead letters and transport expiry. */
87
+ export const retryUnverifiedReplication = (sql, generation) => sql.unsafe(`DELETE FROM _ontology_replication_receipts WHERE generation = $1::uuid
88
+ AND verified_at IS NULL AND accepted_at < now() - interval '15 minutes'`, [generation]);
@@ -0,0 +1,4 @@
1
+ import { Effect } from "effect";
2
+ import type { SqlClient } from "effect/unstable/sql";
3
+ /** Must run inside the write transaction, before any business row locks. */
4
+ export declare const beginReplicationWrite: (sql: SqlClient.SqlClient) => Effect.Effect<void, import("effect/unstable/sql/SqlError").SqlError, never>;
@@ -0,0 +1,11 @@
1
+ import { Effect } from "effect";
2
+ /** Must run inside the write transaction, before any business row locks. */
3
+ export const beginReplicationWrite = (sql) => Effect.gen(function* () {
4
+ const [schema] = yield* sql.unsafe("SELECT to_regclass('_ontology_replication') IS NOT NULL AS present");
5
+ if (!schema?.present) {
6
+ return;
7
+ } // Platform principal operations also serve pre-upgrade databases.
8
+ yield* sql.unsafe("SELECT set_config('segmentstream.writer', '1', true)");
9
+ // Lock even while disabled: enabling and the baseline must exclude concurrent writes.
10
+ yield* sql.unsafe("SELECT id FROM _ontology_replication WHERE id FOR UPDATE");
11
+ });
@@ -1,3 +1,4 @@
1
+ import { Schema } from "effect";
1
2
  import { type AnyAction } from "../action.js";
2
3
  import type { JsonSchemaObject } from "../analyze.js";
3
4
  import type { Ontology } from "../ontology.js";
@@ -14,6 +15,8 @@ export declare function upsertActionName(property: string): string;
14
15
  * and services by the app or the CLI.
15
16
  */
16
17
  export declare function generatedActions(ontology: Ontology, entityName: string): GeneratedActions;
18
+ /** An action's input as JSON Schema (draft 2020-12), as the manifest describes authored actions. */
19
+ export declare function actionInputJsonSchema(input: Schema.Top): JsonSchemaObject;
17
20
  /** A generated action, described as the manifest describes authored ones. */
18
21
  export interface GeneratedActionManifest {
19
22
  readonly name: string;
@@ -166,7 +166,8 @@ function buildGeneratedActions(ontology, entityName) {
166
166
  }
167
167
  return actions;
168
168
  }
169
- function inputJsonSchema(input) {
169
+ /** An action's input as JSON Schema (draft 2020-12), as the manifest describes authored actions. */
170
+ export function actionInputJsonSchema(input) {
170
171
  const document = Schema.toJsonSchemaDocument(input, {
171
172
  includeAnnotationKey: (key) => key === REFERENCE_ANNOTATION || key === BLOB_UPLOAD_ANNOTATION,
172
173
  onExcessProperty: "error",
@@ -182,6 +183,6 @@ export function generatedActionManifests(ontology, entityName) {
182
183
  name,
183
184
  description: generated.description,
184
185
  permissions: generatedActionPermissions(entityName, name),
185
- input: inputJsonSchema(generated.input),
186
+ input: actionInputJsonSchema(generated.input),
186
187
  }));
187
188
  }
@@ -0,0 +1,9 @@
1
+ import { SqlClient } from "effect/unstable/sql";
2
+ import type { Ontology } from "../ontology.js";
3
+ import type { OAuthStore } from "../service/runtime-contract.js";
4
+ /**
5
+ * The OAuth store on the runtime's OAuth tables, as `ontology_app`. Expiry is the database's
6
+ * `now()`; codes and refresh tokens are used with one `UPDATE … WHERE "used_at" IS NULL`, so two
7
+ * concurrent uses cannot both succeed.
8
+ */
9
+ export declare function sqlOAuthStore(ontology: Ontology, sql: SqlClient.SqlClient): OAuthStore;
@@ -0,0 +1,165 @@
1
+ import { Effect } from "effect";
2
+ import { SqlClient } from "effect/unstable/sql";
3
+ import { OAUTH_CLIENTS_TABLE, OAUTH_GRANTS_TABLE, OAUTH_REQUESTS_TABLE, OAUTH_TOKENS_TABLE, } from "../migrate/storage.js";
4
+ import { resolveMemberById } from "./principals.js";
5
+ const CLIENTS = `"${OAUTH_CLIENTS_TABLE}"`;
6
+ const REQUESTS = `"${OAUTH_REQUESTS_TABLE}"`;
7
+ const GRANTS = `"${OAUTH_GRANTS_TABLE}"`;
8
+ const TOKENS = `"${OAUTH_TOKENS_TABLE}"`;
9
+ const CLIENT_COLUMNS = `"id", "name", "redirect_uris"::text AS "redirect_uris", "auth_method", "secret_hash", ` +
10
+ `"client_uri"`;
11
+ /** A timestamp as epoch milliseconds, which every driver returns as a plain number. */
12
+ function epochMs(column) {
13
+ return `(extract(epoch from ${column}) * 1000)::double precision`;
14
+ }
15
+ const REQUEST_COLUMNS = `"id", "client_id", "redirect_uri", "state", "code_challenge", "scope", "resource", ` +
16
+ `${epochMs(`"expires_at"`)} AS "expires_at"`;
17
+ /** The grant's columns, read through the alias `g`. */
18
+ const GRANT_COLUMNS = `"g"."id"::text AS "grant_id", "g"."client_id" AS "client_id", ` +
19
+ `"g"."user_id"::text AS "user_id", "g"."scope" AS "scope", "g"."resource" AS "resource"`;
20
+ function text(value) {
21
+ return String(value);
22
+ }
23
+ function nullableText(value) {
24
+ return value === null || value === undefined ? null : String(value);
25
+ }
26
+ function date(value) {
27
+ return new Date(Number(value));
28
+ }
29
+ function clientOf(row) {
30
+ return {
31
+ id: text(row.id),
32
+ name: text(row.name),
33
+ redirectUris: JSON.parse(text(row.redirect_uris)),
34
+ authMethod: text(row.auth_method),
35
+ secretHash: nullableText(row.secret_hash),
36
+ clientUri: nullableText(row.client_uri),
37
+ };
38
+ }
39
+ function requestOf(row) {
40
+ return {
41
+ id: text(row.id),
42
+ clientId: text(row.client_id),
43
+ redirectUri: text(row.redirect_uri),
44
+ state: nullableText(row.state),
45
+ codeChallenge: text(row.code_challenge),
46
+ scope: text(row.scope),
47
+ resource: text(row.resource),
48
+ expiresAt: date(row.expires_at),
49
+ };
50
+ }
51
+ function grantOf(row) {
52
+ return {
53
+ id: text(row.grant_id),
54
+ clientId: text(row.client_id),
55
+ userId: text(row.user_id),
56
+ scope: text(row.scope),
57
+ resource: text(row.resource),
58
+ };
59
+ }
60
+ function tokenOf(row) {
61
+ return {
62
+ hash: text(row.hash),
63
+ grantId: text(row.grant_id),
64
+ kind: text(row.kind),
65
+ expiresAt: date(row.expires_at),
66
+ ...(row.redirect_uri === null ? {} : { redirectUri: text(row.redirect_uri) }),
67
+ ...(row.code_challenge === null ? {} : { codeChallenge: text(row.code_challenge) }),
68
+ };
69
+ }
70
+ const INSERT_TOKEN = `INSERT INTO ${TOKENS} ("hash", "grant_id", "kind", "redirect_uri", "code_challenge", ` +
71
+ `"expires_at") VALUES ($1, $2::uuid, $3, $4, $5, $6::timestamptz)`;
72
+ function tokenValues(token) {
73
+ return [
74
+ token.hash,
75
+ token.grantId,
76
+ token.kind,
77
+ token.redirectUri ?? null,
78
+ token.codeChallenge ?? null,
79
+ token.expiresAt.toISOString(),
80
+ ];
81
+ }
82
+ /**
83
+ * The OAuth store on the runtime's OAuth tables, as `ontology_app`. Expiry is the database's
84
+ * `now()`; codes and refresh tokens are used with one `UPDATE … WHERE "used_at" IS NULL`, so two
85
+ * concurrent uses cannot both succeed.
86
+ */
87
+ export function sqlOAuthStore(ontology, sql) {
88
+ const rows = (statement, values = []) => sql.unsafe(statement, values).pipe(Effect.orDie);
89
+ return {
90
+ saveClient: (client) => rows(`INSERT INTO ${CLIENTS} ("id", "name", "redirect_uris", "auth_method", "secret_hash", ` +
91
+ `"client_uri") VALUES ($1, $2, $3::jsonb, $4, $5, $6)`, [
92
+ client.id,
93
+ client.name,
94
+ JSON.stringify(client.redirectUris),
95
+ client.authMethod,
96
+ client.secretHash,
97
+ client.clientUri,
98
+ ]).pipe(Effect.asVoid),
99
+ findClient: (id) => rows(`SELECT ${CLIENT_COLUMNS} FROM ${CLIENTS} WHERE "id" = $1`, [id]).pipe(Effect.map(([row]) => (row === undefined ? undefined : clientOf(row)))),
100
+ saveRequest: (request) => sql
101
+ .withTransaction(Effect.gen(function* () {
102
+ yield* rows(`DELETE FROM ${REQUESTS} WHERE "expires_at" <= now()`);
103
+ yield* rows(`INSERT INTO ${REQUESTS} ("id", "client_id", "redirect_uri", "state", ` +
104
+ `"code_challenge", "scope", "resource", "expires_at") ` +
105
+ `VALUES ($1, $2, $3, $4, $5, $6, $7, $8::timestamptz)`, [
106
+ request.id,
107
+ request.clientId,
108
+ request.redirectUri,
109
+ request.state,
110
+ request.codeChallenge,
111
+ request.scope,
112
+ request.resource,
113
+ request.expiresAt.toISOString(),
114
+ ]);
115
+ }))
116
+ .pipe(Effect.orDie),
117
+ findRequest: (id) => rows(`SELECT ${REQUEST_COLUMNS} FROM ${REQUESTS} WHERE "id" = $1 AND "expires_at" > now()`, [
118
+ id,
119
+ ]).pipe(Effect.map(([row]) => (row === undefined ? undefined : requestOf(row)))),
120
+ takeRequest: (id) => rows(`DELETE FROM ${REQUESTS} WHERE "id" = $1 AND "expires_at" > now() ` +
121
+ `RETURNING ${REQUEST_COLUMNS}`, [id]).pipe(Effect.map(([row]) => (row === undefined ? undefined : requestOf(row)))),
122
+ saveGrant: (grant, code) => sql
123
+ .withTransaction(Effect.gen(function* () {
124
+ yield* rows(`INSERT INTO ${GRANTS} ("id", "client_id", "user_id", "scope", "resource") ` +
125
+ `VALUES ($1::uuid, $2, $3::uuid, $4, $5)`, [grant.id, grant.clientId, grant.userId, grant.scope, grant.resource]);
126
+ yield* rows(INSERT_TOKEN, tokenValues(code));
127
+ }))
128
+ .pipe(Effect.orDie),
129
+ useToken: (hash, kind) => Effect.gen(function* () {
130
+ const [used] = yield* rows(`UPDATE ${TOKENS} AS "t" SET "used_at" = now() FROM ${GRANTS} AS "g" ` +
131
+ `WHERE "t"."hash" = $1 AND "t"."kind" = $2 AND "t"."used_at" IS NULL ` +
132
+ `AND "t"."expires_at" > now() AND "g"."id" = "t"."grant_id" ` +
133
+ `AND "g"."revoked_at" IS NULL ` +
134
+ `RETURNING "t"."hash" AS "hash", "t"."kind" AS "kind", ` +
135
+ `"t"."redirect_uri" AS "redirect_uri", "t"."code_challenge" AS "code_challenge", ` +
136
+ `${epochMs(`"t"."expires_at"`)} AS "expires_at", ${GRANT_COLUMNS}`, [hash, kind]);
137
+ if (used !== undefined) {
138
+ return { status: "valid", token: tokenOf(used), grant: grantOf(used) };
139
+ }
140
+ const [reused] = yield* rows(`SELECT ${GRANT_COLUMNS} FROM ${TOKENS} AS "t" ` +
141
+ `JOIN ${GRANTS} AS "g" ON "g"."id" = "t"."grant_id" ` +
142
+ `WHERE "t"."hash" = $1 AND "t"."kind" = $2 AND "t"."used_at" IS NOT NULL ` +
143
+ `AND "t"."expires_at" > now() AND "g"."revoked_at" IS NULL`, [hash, kind]);
144
+ return reused === undefined
145
+ ? { status: "invalid" }
146
+ : { status: "reused", grant: grantOf(reused) };
147
+ }),
148
+ saveTokens: (tokens) => sql
149
+ .withTransaction(Effect.gen(function* () {
150
+ yield* rows(`DELETE FROM ${TOKENS} WHERE "expires_at" <= now()`);
151
+ for (const token of tokens) {
152
+ yield* rows(INSERT_TOKEN, tokenValues(token));
153
+ }
154
+ }))
155
+ .pipe(Effect.orDie),
156
+ findAccess: (hash) => rows(`SELECT ${GRANT_COLUMNS} FROM ${TOKENS} AS "t" ` +
157
+ `JOIN ${GRANTS} AS "g" ON "g"."id" = "t"."grant_id" ` +
158
+ `WHERE "t"."hash" = $1 AND "t"."kind" = 'access' AND "t"."expires_at" > now() ` +
159
+ `AND "g"."revoked_at" IS NULL`, [hash]).pipe(Effect.map(([row]) => (row === undefined ? undefined : grantOf(row)))),
160
+ findGrantOfToken: (hash) => rows(`SELECT ${GRANT_COLUMNS} FROM ${TOKENS} AS "t" ` +
161
+ `JOIN ${GRANTS} AS "g" ON "g"."id" = "t"."grant_id" WHERE "t"."hash" = $1`, [hash]).pipe(Effect.map(([row]) => (row === undefined ? undefined : grantOf(row)))),
162
+ revokeGrant: (id) => rows(`UPDATE ${GRANTS} SET "revoked_at" = now() WHERE "id" = $1::uuid AND "revoked_at" IS NULL`, [id]).pipe(Effect.asVoid),
163
+ memberPrincipal: (userId) => resolveMemberById(ontology, userId).pipe(Effect.provideService(SqlClient.SqlClient, sql)),
164
+ };
165
+ }
@@ -1,4 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
+ import { beginReplicationWrite } from "../replication/transaction.js";
2
3
  import { Cause, Duration, Effect, Option, Schema } from "effect";
3
4
  import { SqlClient } from "effect/unstable/sql";
4
5
  import { isSqlError } from "effect/unstable/sql/SqlError";
@@ -204,7 +205,7 @@ export function runAction(ontology, request) {
204
205
  return result;
205
206
  });
206
207
  const result = yield* sql
207
- .withTransaction(transactionalHandler)
208
+ .withTransaction(beginReplicationWrite(sql).pipe(Effect.andThen(transactionalHandler)))
208
209
  .pipe(Effect.catchIf(isSqlError, (error) => Effect.die(error)));
209
210
  if (replay) {
210
211
  return replay;
@@ -54,6 +54,11 @@ export declare function resolvePrincipalByIdentity(ontology: Ontology, claims: I
54
54
  * revoked keys and inactive users are rejected.
55
55
  */
56
56
  export declare function resolvePrincipalByApiKey(ontology: Ontology, key: string): Effect.Effect<Principal, PrincipalRejected, SqlClient.SqlClient>;
57
+ /**
58
+ * A member by user id, with the roles they hold now: whom an OAuth grant acts for. `undefined` for
59
+ * an unknown user, a service and an inactive member.
60
+ */
61
+ export declare function resolveMemberById(ontology: Ontology, userId: string): Effect.Effect<Principal | undefined, never, SqlClient.SqlClient>;
57
62
  export interface CreatedApiKey {
58
63
  readonly id: string;
59
64
  /** The key itself. It is not stored and cannot be shown again. */
@@ -11,6 +11,7 @@ import { buildOntology } from "../ontology.js";
11
11
  import { permissionsOf } from "../permissions.js";
12
12
  import { EMAIL_PATTERN } from "../property.js";
13
13
  import { role } from "../role.js";
14
+ import { beginReplicationWrite } from "../replication/transaction.js";
14
15
  import { User } from "../user.js";
15
16
  import { MemberNotFound, PrincipalRejected, UnknownRole } from "./errors.js";
16
17
  import { isRecordId } from "./records.js";
@@ -116,6 +117,7 @@ function signIn(ontology, sql, claims) {
116
117
  const email = claimedEmail !== undefined && EMAIL_PATTERN.test(claimedEmail) ? claimedEmail : undefined;
117
118
  return sql
118
119
  .withTransaction(Effect.gen(function* () {
120
+ yield* beginReplicationWrite(sql);
119
121
  const now = new Date();
120
122
  const signedIn = { last_sign_in_at: now.toISOString() };
121
123
  const [linked] = yield* runtime.find({
@@ -241,6 +243,21 @@ export function resolvePrincipalByApiKey(ontology, key) {
241
243
  return yield* activePrincipal(ontology, sql, decodeUser({ id: row.id, kind: row.kind, active: row.active }));
242
244
  }).pipe(Effect.catchIf(isSqlError, (error) => Effect.die(error)));
243
245
  }
246
+ /**
247
+ * A member by user id, with the roles they hold now: whom an OAuth grant acts for. `undefined` for
248
+ * an unknown user, a service and an inactive member.
249
+ */
250
+ export function resolveMemberById(ontology, userId) {
251
+ return Effect.gen(function* () {
252
+ const sql = yield* SqlClient.SqlClient;
253
+ const [row] = yield* sql.unsafe(`SELECT "id"::text AS "id", "kind"::text AS "kind", "active" AS "active" ` +
254
+ `FROM "${USER_TABLE}" WHERE "id" = $1::uuid`, [userId]);
255
+ if (row === undefined || row.kind !== "member" || row.active !== true) {
256
+ return undefined;
257
+ }
258
+ return yield* rolesOf(ontology, sql, decodeUser(row));
259
+ }).pipe(Effect.orDie);
260
+ }
244
261
  /** Issues an API key for a user. */
245
262
  export function createApiKey(input) {
246
263
  return Effect.gen(function* () {
@@ -280,6 +297,7 @@ export function setUserRoles(ontology, input) {
280
297
  }
281
298
  const roles = [...new Set(input.roles)];
282
299
  yield* sql.withTransaction(Effect.gen(function* () {
300
+ yield* beginReplicationWrite(sql);
283
301
  yield* sql.unsafe(`DELETE FROM "${USER_ROLES_TABLE}" WHERE "user_id" = $1::uuid`, [
284
302
  input.userId,
285
303
  ]);
@@ -298,6 +316,7 @@ export function createServiceUser(ontology, input) {
298
316
  const sql = yield* SqlClient.SqlClient;
299
317
  const runtime = platformRuntime(ontology, sql, "user.create_service");
300
318
  return yield* sql.withTransaction(Effect.gen(function* () {
319
+ yield* beginReplicationWrite(sql);
301
320
  const user = yield* runtime
302
321
  .create({ entity: "user", values: { name: input.name, kind: "service" } })
303
322
  .pipe(Effect.orDie);
@@ -561,6 +580,7 @@ export function updateMemberRoles(ontology, change) {
561
580
  yield* refuseMember(revoke === "all" ? [] : revoke);
562
581
  const runtime = platformRuntime(ontology, sql, "user.invite");
563
582
  const attempt = sql.withTransaction(Effect.gen(function* () {
583
+ yield* beginReplicationWrite(sql);
564
584
  const found = yield* lockUser(runtime, { email });
565
585
  if (found !== undefined && found.kind !== "member") {
566
586
  return yield* new MemberNotFound({
@@ -621,6 +641,7 @@ export function setMemberActive(ontology, input) {
621
641
  const action = input.active ? "user.reactivate" : "user.deactivate";
622
642
  const runtime = platformRuntime(ontology, sql, action);
623
643
  return yield* sql.withTransaction(Effect.gen(function* () {
644
+ yield* beginReplicationWrite(sql);
624
645
  const user = yield* lockUser(runtime, input);
625
646
  if (user === undefined || user.kind !== "member") {
626
647
  return yield* memberNotFound(input);
@@ -7,6 +7,7 @@ import { blobRuntime } from "../storage/sessions.js";
7
7
  import { readCatalog } from "../migrate/apply.js";
8
8
  import { NotInvited, OntologyBackend, } from "../service/runtime-contract.js";
9
9
  import { generatedActions } from "./generated-actions.js";
10
+ import { sqlOAuthStore } from "./oauth.js";
10
11
  import { runAction } from "./pipeline.js";
11
12
  import { checkSignIn, resolvePrincipalByApiKey, resolvePrincipalByIdentity } from "./principals.js";
12
13
  import { postgresTls } from "./postgres-tls.js";
@@ -59,6 +60,15 @@ export function backendLayer(ontology, blobStorage) {
59
60
  checkSignIn: (email) => withSql(checkSignIn(ontology, email)),
60
61
  resolveApiKey: (key) => withSql(resolvePrincipalByApiKey(ontology, Redacted.value(key))).pipe(Effect.catchTag("PrincipalRejected", rejectedAs("api_key"))),
61
62
  appliedHash: readCatalog({ sql }).pipe(Effect.map((manifest) => manifest?.hash), Effect.orDie),
63
+ replicationEnabled: Effect.gen(function* () {
64
+ const [presence] = yield* sql.unsafe("SELECT to_regclass('_ontology_replication') IS NOT NULL AS present");
65
+ if (!presence?.present) {
66
+ return false;
67
+ }
68
+ const [state] = yield* sql.unsafe("SELECT enabled FROM _ontology_replication WHERE id");
69
+ return state?.enabled === true;
70
+ }).pipe(Effect.orDie),
71
+ oauth: sqlOAuthStore(ontology, sql),
62
72
  });
63
73
  }));
64
74
  }
@@ -1,4 +1,5 @@
1
1
  import { Effect } from "effect";
2
+ import { beginReplicationWrite } from "../replication/transaction.js";
2
3
  import { isSqlError } from "effect/unstable/sql/SqlError";
3
4
  import { Conflict } from "../errors.js";
4
5
  import { InvalidQuery, WriteOutsideAction } from "./errors.js";
@@ -67,7 +68,7 @@ export function makeOntologyRuntime(ontology, sql, scope) {
67
68
  }
68
69
  const edits = [];
69
70
  const result = yield* sql
70
- .withTransaction(operation({ ...context, scope, edits }, entity))
71
+ .withTransaction(beginReplicationWrite(sql).pipe(Effect.andThen(operation({ ...context, scope, edits }, entity))))
71
72
  .pipe(Effect.catchIf(isSqlError, (error) => uniqueConflict(entity, error)));
72
73
  for (const edit of edits) {
73
74
  scope.onEdit?.(edit);
@@ -5,6 +5,7 @@ import { DownloadRequest, TransferGrant } from "../storage/sessions.js";
5
5
  import { permissionOf, READ_VERB } from "../permissions.js";
6
6
  import { generatedActions } from "../runtime/generated-actions.js";
7
7
  import { Authentication } from "./authentication.js";
8
+ import { actionDescription, requires } from "./descriptions.js";
8
9
  import { InvalidRequestBody, RequestId, failureResponse } from "./errors.js";
9
10
  import { planApi, } from "./plan.js";
10
11
  import { ActionResponse, AsParams, Catalog, ErrorSchemas, Graph, HistoryPage, HistoryParams, IdParam, Me, SignInCheckRequest, SignInCheckResult, Status, actionRequest, entitySchemas, } from "./schemas.js";
@@ -58,10 +59,6 @@ const connectionActionErrors = [
58
59
  ErrorSchemas.connectionResponseInvalid,
59
60
  ErrorSchemas.connectionUnavailable,
60
61
  ];
61
- /** `Requires lead:read.`, `Requires lead:create and lead:update.` */
62
- function requires(permissions) {
63
- return `Requires ${permissions.join(" and ")}.`;
64
- }
65
62
  function readPermission(entity) {
66
63
  return permissionOf(entity, READ_VERB);
67
64
  }
@@ -75,17 +72,14 @@ function traversalEndpoint(planned, traversal, target, targetNames) {
75
72
  return documented(endpoint, `${label} ${traversal.phrase} ${reach} (${traversal.name})`, `${traversal.description}\n\n${requires(permissions)} Including a relationship requires ` +
76
73
  "reading its entity too.");
77
74
  }
78
- function actionEndpoint(planned, { name, action, generated, permissions }) {
75
+ function actionEndpoint(planned, plannedAction) {
76
+ const { name, action } = plannedAction;
79
77
  const endpoint = HttpApiEndpoint.post(`actions.${name}`, `${BASE_PATH}/${planned.name}/actions/${name}`, {
80
78
  payload: actionRequest(action.input),
81
79
  success: ActionResponse,
82
80
  error: action.connections.length > 0 ? connectionActionErrors : actionErrors,
83
81
  });
84
- const origin = generated ? "Generated action." : "Action.";
85
- const calls = action.connections.length > 0
86
- ? ` Calls ${action.connections.map((handle) => handle.key).join(", ")} before its transaction.`
87
- : "";
88
- return documented(endpoint, `${planned.name}.${name}`, `${action.description}\n\n${origin} ${requires(permissions)}${calls}`);
82
+ return documented(endpoint, `${planned.name}.${name}`, actionDescription(plannedAction));
89
83
  }
90
84
  function entityGroup(planned, schemas, names) {
91
85
  const own = schemas.get(planned.name);
@@ -1,4 +1,5 @@
1
1
  import { Context, Effect, Layer, Redacted } from "effect";
2
+ import { HttpServerRequest, type HttpServerResponse } from "effect/unstable/http";
2
3
  import { HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi";
3
4
  import { type JWTVerifyGetKey } from "jose";
4
5
  import type { Principal } from "../runtime.js";
@@ -117,8 +118,33 @@ type Authenticated = {
117
118
  } | {
118
119
  readonly status: "unavailable";
119
120
  };
121
+ /** Where a credential is presented. */
122
+ export interface CredentialOptions {
123
+ /**
124
+ * The resource OAuth access tokens must have been issued for: the MCP server's URL. Elsewhere,
125
+ * an access token is rejected, as it was issued for the MCP server only.
126
+ */
127
+ readonly oauthResource?: string;
128
+ }
120
129
  /** Resolves a bearer credential to a principal. */
121
- export declare function authenticate(credential: Redacted.Redacted<string>, verifyToken: TokenVerifier): Effect.Effect<Authenticated, never, OntologyBackend>;
130
+ export declare function authenticate(credential: Redacted.Redacted<string>, verifyToken: TokenVerifier, options?: CredentialOptions): Effect.Effect<Authenticated, never, OntologyBackend>;
131
+ /** A browser's request, which may authenticate with the app's Neon Auth session cookie. */
132
+ export interface BrowserSession {
133
+ readonly request: HttpServerRequest.HttpServerRequest;
134
+ readonly exchange: SessionExchange | undefined;
135
+ }
136
+ /**
137
+ * Runs `effect` as the principal a bearer credential names, or answers the request as every route
138
+ * does: 401 without a valid credential, 403 for a person nobody invited or a session cookie sent
139
+ * from another origin, 503 while sign-in cannot be verified, and a sanitized 500 when
140
+ * authentication itself fails.
141
+ */
142
+ export declare function withPrincipal<E, R>(credential: Redacted.Redacted<string>, verifyToken: TokenVerifier, backend: OntologyBackend["Service"], effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>, options?: CredentialOptions & {
143
+ /** The `WWW-Authenticate` challenge of a 401, by whether the credential was missing. */
144
+ readonly challenge?: (missing: boolean) => string;
145
+ /** Without a bearer token, the app's session cookie may authenticate this browser request. */
146
+ readonly session?: BrowserSession;
147
+ }): Effect.Effect<HttpServerResponse.HttpServerResponse, E, Exclude<R, CurrentPrincipal> | RequestId>;
122
148
  /**
123
149
  * The {@link Authentication} middleware, verifying tokens with `verifyToken`. With `exchangeSession`,
124
150
  * a request without a bearer token may authenticate with the app's Neon Auth session cookie.