@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.
- package/README.md +6 -0
- package/dist/action.d.ts +19 -0
- package/dist/action.js +1 -0
- package/dist/analyze.d.ts +3 -1
- package/dist/analyze.js +81 -1
- package/dist/bin/migrate.js +3 -2
- package/dist/diagnostics.d.ts +3 -3
- package/dist/diagnostics.js +1 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +4 -1
- package/dist/manifest.d.ts +48 -0
- package/dist/manifest.js +9 -0
- package/dist/migrate/apply.d.ts +8 -1
- package/dist/migrate/apply.js +54 -4
- package/dist/migrate/index.d.ts +1 -1
- package/dist/migrate/index.js +1 -1
- package/dist/migrate/model.d.ts +8 -0
- package/dist/migrate/plan.d.ts +6 -0
- package/dist/migrate/plan.js +10 -2
- package/dist/migrate/storage.d.ts +12 -0
- package/dist/migrate/storage.js +82 -0
- package/dist/names.d.ts +9 -1
- package/dist/names.js +11 -0
- package/dist/nitro.d.ts +13 -0
- package/dist/nitro.js +18 -0
- package/dist/replication/baseline.d.ts +19 -0
- package/dist/replication/baseline.js +97 -0
- package/dist/replication/bigquery.d.ts +23 -0
- package/dist/replication/bigquery.js +63 -0
- package/dist/replication/model.d.ts +38 -0
- package/dist/replication/model.js +89 -0
- package/dist/replication/schema.d.ts +7 -0
- package/dist/replication/schema.js +123 -0
- package/dist/replication/store.d.ts +35 -0
- package/dist/replication/store.js +88 -0
- package/dist/replication/transaction.d.ts +4 -0
- package/dist/replication/transaction.js +11 -0
- package/dist/runtime/generated-actions.d.ts +3 -0
- package/dist/runtime/generated-actions.js +3 -2
- package/dist/runtime/oauth.d.ts +9 -0
- package/dist/runtime/oauth.js +165 -0
- package/dist/runtime/pipeline.js +2 -1
- package/dist/runtime/principals.d.ts +5 -0
- package/dist/runtime/principals.js +21 -0
- package/dist/runtime/service-binding.js +10 -0
- package/dist/runtime/sql-runtime.js +2 -1
- package/dist/service/api.js +4 -10
- package/dist/service/authentication.d.ts +27 -1
- package/dist/service/authentication.js +48 -26
- package/dist/service/config.d.ts +5 -0
- package/dist/service/descriptions.d.ts +8 -0
- package/dist/service/descriptions.js +15 -0
- package/dist/service/errors.d.ts +5 -1
- package/dist/service/errors.js +6 -2
- package/dist/service/handlers.d.ts +3 -5
- package/dist/service/handlers.js +21 -204
- package/dist/service/mcp/server.d.ts +26 -0
- package/dist/service/mcp/server.js +370 -0
- package/dist/service/mcp/tools.d.ts +47 -0
- package/dist/service/mcp/tools.js +218 -0
- package/dist/service/oauth/protocol.d.ts +52 -0
- package/dist/service/oauth/protocol.js +110 -0
- package/dist/service/oauth/server.d.ts +27 -0
- package/dist/service/oauth/server.js +576 -0
- package/dist/service/operations.d.ts +115 -0
- package/dist/service/operations.js +228 -0
- package/dist/service/replication.d.ts +5 -0
- package/dist/service/replication.js +25 -0
- package/dist/service/runtime-contract.d.ts +90 -0
- package/dist/service/schemas.d.ts +8 -0
- package/dist/service/service.d.ts +3 -3
- package/dist/service/service.js +13 -6
- 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
|
-
|
|
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:
|
|
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
|
+
}
|
package/dist/runtime/pipeline.js
CHANGED
|
@@ -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);
|
package/dist/service/api.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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.
|