@segmentstream-cloud/ontology 0.13.0 → 0.14.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 CHANGED
@@ -28,6 +28,9 @@ one Effect version.
28
28
  `ontology/` is a package of its own. The SDK is an ES module, so `ontology/package.json`
29
29
  must set `"type": "module"`.
30
30
 
31
+ SDK 0.14.0 lets the app's own agent call the service as a paired member (see
32
+ [Agents](#agents)). Its migration adds the agent's runtime tables.
33
+
31
34
  SDK 0.12.0 adds warehouse replication enabled by default for all stored business entities,
32
35
  fields and business relationships. Existing history captures immutable snapshots and the
33
36
  service wakes the publisher after commits. Upgrade and redeploy to activate it; workspace
@@ -211,9 +214,12 @@ one is missing or invalid, and never repeats the database URL.
211
214
  | `NEON_AUTH_BASE_URL` | Optional. The workspace's Neon Auth URL; its origin issues users' tokens. |
212
215
  | `NEON_AUTH_JWKS_URL` | Optional, set with the base URL. The keys that verify those tokens. |
213
216
  | `SEGMENTSTREAM_APP_CREDENTIAL` | Optional. The workspace app credential; without it, aggregates answer 503. |
217
+ | `SEGMENTSTREAM_RUNTIME_OIDC_ISSUER` | Optional. The team's Vercel OIDC issuer, for the app's agent. |
218
+ | `SEGMENTSTREAM_RUNTIME_OIDC_AUDIENCE` | Optional, set with the issuer. The audience of the team's tokens. |
214
219
 
215
220
  The platform sets these variables in the workspace deployment, the Neon Auth pair once the
216
- workspace has Neon Auth.
221
+ workspace has Neon Auth and the runtime pair on deployments with an agent. With the runtime
222
+ pair, Vercel's `VERCEL_PROJECT_ID` must be set too.
217
223
 
218
224
  ### Authentication
219
225
 
@@ -249,6 +255,37 @@ address (`x-real-ip`, else the first `x-forwarded-for` hop) ten checks a minute,
249
255
  answers 429 `rate_limited` with `retry-after`. `checkSignIn(ontology, email)` is the same
250
256
  lookup for code that holds a SQL client.
251
257
 
258
+ ## Agents
259
+
260
+ The app's own agent runs in the same Vercel project as the service and calls it as the
261
+ deployment: `Authorization: Bearer <Vercel OIDC token>`. The service accepts the token when it
262
+ is RS256 from `SEGMENTSTREAM_RUNTIME_OIDC_ISSUER` (keys at `<issuer>/.well-known/jwks`), for
263
+ `SEGMENTSTREAM_RUNTIME_OIDC_AUDIENCE`, with `project_id` equal to `VERCEL_PROJECT_ID` and
264
+ `environment` `production`. Without the issuer and audience such tokens are a 401.
265
+
266
+ The agent acts for a member by adding `X-SegmentStream-Act-As: <user id>` on any route,
267
+ `/mcp` included: the request runs with the member's current roles and history names the
268
+ member, as if they had called. A member who is inactive or has no live pairing is a 403.
269
+ Without the header the agent may call only the agent routes:
270
+
271
+ | Route | Caller | Does |
272
+ | ------------------------------------------------------------- | ------ | -------------------------------------------------------------------- |
273
+ | `POST /api/ontology/agent/pairings` | agent | `{ provider, external_id, display? }` → `{ code, expires_at }` |
274
+ | `GET /api/ontology/agent/pairings/{code}` | member | `{ provider, display, expires_at }`, for the connect page |
275
+ | `POST /api/ontology/agent/pairings/{code}/approve` | member | Pairs the account with the member → `{ identity }` |
276
+ | `GET /api/ontology/agent/identities/{provider}/{external_id}` | agent | `{ user: { id, name }, roles }`; 404 unpaired, 403 revoked, inactive |
277
+ | `GET /api/ontology/agent/identities` | member | `{ identities }`: the member's live identities |
278
+ | `DELETE /api/ontology/agent/identities/{id}` | member | Revokes one of them: 204 |
279
+
280
+ A pairing code is eight characters without look-alikes (no 0, O, 1, I or L), stored as its
281
+ SHA-256, valid ten minutes and used once. Each instance allows a client address 60 new
282
+ pairings a minute. An account and a member each have one live identity per provider:
283
+ approving a code revokes the account's earlier identity and the member's earlier one on the
284
+ same provider. Member routes take a Neon Auth session or token and refuse API keys and the
285
+ agent, even acting as a member, so only members pair. Pairings and identities live in the
286
+ runtime tables `_agent_pairings` and `_agent_identities`; the migration creates them in a
287
+ database an earlier SDK migrated.
288
+
252
289
  ## The `segmentstream-ontology` binary
253
290
 
254
291
  Run the binary in the `ontology/` folder. The entry defaults to `./index.ts`. The binary
package/dist/index.d.ts CHANGED
@@ -52,11 +52,14 @@ export type { DeriveApiOptions, OntologyApi } from "./service/api.js";
52
52
  export { CurrentPrincipal } from "./service/authentication.js";
53
53
  export { deployedConnections, LocalConnectionContextError, localConnections, OIDC_TOKEN_HEADER, parseLocalConnectionContext, readLocalConnectionContext, RUNTIME_CONTEXT_FD, } from "./service/connections.js";
54
54
  export type { ConnectionAudit, ConnectionTransportFactory, LocalConnection, LocalConnectionContext, } from "./service/connections.js";
55
- export { configFromEnv, ENVIRONMENT_VARIABLES, NEON_AUTH_ENVIRONMENT_VARIABLES, OntologyServiceConfigError, } from "./service/config.js";
55
+ export { configFromEnv, ENVIRONMENT_VARIABLES, NEON_AUTH_ENVIRONMENT_VARIABLES, OntologyServiceConfigError, RUNTIME_IDENTITY_ENVIRONMENT_VARIABLES, } from "./service/config.js";
56
56
  export type { ActionCommitted, AfterCommitHook, NeonAuthEnvironment, OntologyEnvironment, OntologyServiceConfig, } from "./service/config.js";
57
57
  export { NotInvited, OntologyBackend } from "./service/runtime-contract.js";
58
- export type { GeneratedActionsProvider, IdentityProvider, OAuthClient, OAuthClientAuthMethod, OAuthGrant, OAuthRequest, OAuthStore, OAuthToken, OAuthTokenKind, OntologyServiceRuntime, RunActionInput, SignInIdentity, UsedOAuthToken, } from "./service/runtime-contract.js";
58
+ export type { AgentIdentity, AgentMember, AgentPairing, AgentStore, GeneratedActionsProvider, IdentityProvider, OAuthClient, OAuthClientAuthMethod, OAuthGrant, OAuthRequest, OAuthStore, OAuthToken, OAuthTokenKind, OntologyServiceRuntime, RunActionInput, SignInIdentity, UsedOAuthToken, } from "./service/runtime-contract.js";
59
59
  export { createOntologyService, ONTOLOGY_HASH_HEADER, REQUEST_ID_HEADER, } from "./service/service.js";
60
+ export { AGENT_PATH } from "./service/agent/server.js";
61
+ export { ACT_AS_HEADER } from "./service/runtime-identity.js";
62
+ export type { RuntimeIdentityConfig } from "./service/runtime-identity.js";
60
63
  export { MCP_PATH, MCP_PROTOCOL_VERSIONS } from "./service/mcp/server.js";
61
64
  export { MCP_TOOL_NAME_MAX_LENGTH, mcpToolName } from "./names.js";
62
65
  export type { OntologyService } from "./service/service.js";
package/dist/index.js CHANGED
@@ -31,9 +31,12 @@ export { makeOntologyRuntime } from "./runtime/sql-runtime.js";
31
31
  export { BASE_PATH, deriveApi } from "./service/api.js";
32
32
  export { CurrentPrincipal } from "./service/authentication.js";
33
33
  export { deployedConnections, LocalConnectionContextError, localConnections, OIDC_TOKEN_HEADER, parseLocalConnectionContext, readLocalConnectionContext, RUNTIME_CONTEXT_FD, } from "./service/connections.js";
34
- export { configFromEnv, ENVIRONMENT_VARIABLES, NEON_AUTH_ENVIRONMENT_VARIABLES, OntologyServiceConfigError, } from "./service/config.js";
34
+ export { configFromEnv, ENVIRONMENT_VARIABLES, NEON_AUTH_ENVIRONMENT_VARIABLES, OntologyServiceConfigError, RUNTIME_IDENTITY_ENVIRONMENT_VARIABLES, } from "./service/config.js";
35
35
  export { NotInvited, OntologyBackend } from "./service/runtime-contract.js";
36
36
  export { createOntologyService, ONTOLOGY_HASH_HEADER, REQUEST_ID_HEADER, } from "./service/service.js";
37
+ // The app's own agent: pairing channel accounts with members, and acting as them.
38
+ export { AGENT_PATH } from "./service/agent/server.js";
39
+ export { ACT_AS_HEADER } from "./service/runtime-identity.js";
37
40
  // The MCP server the service serves beside the HTTP API.
38
41
  export { MCP_PATH, MCP_PROTOCOL_VERSIONS } from "./service/mcp/server.js";
39
42
  export { MCP_TOOL_NAME_MAX_LENGTH, mcpToolName } from "./names.js";
@@ -7,7 +7,7 @@ import { planMigration } from "./plan.js";
7
7
  import { REPLICATION_DDL, REPLICATION_SCHEMA_VERSION, replicationEntityDdl, } from "../replication/schema.js";
8
8
  import { createReplicationBaseline } from "../replication/baseline.js";
9
9
  import { resolveReplicationSelection } from "../replication/model.js";
10
- import { API_KEYS_TABLE, CATALOG_TABLE, OAUTH_TABLES, USER_ROLES_TABLE, quoteIdentifier, quoteLiteral, } from "./storage.js";
10
+ import { AGENT_TABLES, API_KEYS_TABLE, CATALOG_TABLE, OAUTH_TABLES, USER_ROLES_TABLE, quoteIdentifier, quoteLiteral, } from "./storage.js";
11
11
  const CATALOG = quoteIdentifier(CATALOG_TABLE);
12
12
  /** Serializes migrations of one database; transaction-scoped, so it ends with the transaction. */
13
13
  const MIGRATION_LOCK = "segmentstream.ontology.migration";
@@ -171,6 +171,7 @@ export const RUNTIME_TABLES = [
171
171
  USER_ROLES_TABLE,
172
172
  API_KEYS_TABLE,
173
173
  ...OAUTH_TABLES,
174
+ ...AGENT_TABLES,
174
175
  ];
175
176
  /**
176
177
  * The runtime tables the database has, for `planMigration`'s `existingTables`: a database that an
@@ -1,7 +1,7 @@
1
1
  import { isValueMatched, } from "../manifest.js";
2
2
  import { BLOB_TABLES_DDL } from "../storage/tables.js";
3
3
  import { SNAKE_CASE_NAME, isSystemProperty, toSnakeCase } from "../names.js";
4
- import { CATALOG_TABLE_DDL, MAX_IDENTIFIER_LENGTH, OAUTH_TABLES, OAUTH_TABLES_DDL, USER_TABLE, addConstraintStatement, columnDefinition, constraintDefinition, createEnumStatement, createIndexStatement, createTableStatement, defaultExpression, entityStorage, enumTypeName, fieldColumnName, joinTable, propertyCheck, blobForeignKey, propertyColumn, propertyEnum, quoteIdentifier, quoteLiteral, relationshipColumn, RUNTIME_TABLES_DDL, relationshipForeignKey, SYSTEM_COLUMNS, tableName, } from "./storage.js";
4
+ import { AGENT_TABLES, AGENT_TABLES_DDL, CATALOG_TABLE_DDL, MAX_IDENTIFIER_LENGTH, OAUTH_TABLES, OAUTH_TABLES_DDL, USER_TABLE, addConstraintStatement, columnDefinition, constraintDefinition, createEnumStatement, createIndexStatement, createTableStatement, defaultExpression, entityStorage, enumTypeName, fieldColumnName, joinTable, propertyCheck, blobForeignKey, propertyColumn, propertyEnum, quoteIdentifier, quoteLiteral, relationshipColumn, RUNTIME_TABLES_DDL, relationshipForeignKey, SYSTEM_COLUMNS, tableName, } from "./storage.js";
5
5
  const GLOBAL_KINDS = new Set(["table", "type", "index"]);
6
6
  function step(input) {
7
7
  return {
@@ -70,17 +70,29 @@ export function planMigration(input) {
70
70
  draft.runtimeTables.push(step({
71
71
  kind: "createCatalog",
72
72
  classification: "additive",
73
- description: "Create the runtime's role assignment, API key and OAuth tables.",
73
+ description: "Create the runtime's role assignment, API key, OAuth and agent tables.",
74
74
  sql: RUNTIME_TABLES_DDL,
75
75
  }));
76
76
  }
77
- else if (OAUTH_TABLES.some((table) => input.existingTables?.has(table) === false)) {
78
- draft.runtimeTables.push(step({
79
- kind: "createCatalog",
80
- classification: "additive",
81
- description: "Create the runtime's OAuth tables, which connect agents to the MCP server.",
82
- sql: OAUTH_TABLES_DDL,
83
- }));
77
+ else {
78
+ // A database an earlier SDK migrated lacks the tables added since; each set arrives on its own.
79
+ const lacks = (tables) => tables.some((table) => input.existingTables?.has(table) === false);
80
+ if (lacks(OAUTH_TABLES)) {
81
+ draft.runtimeTables.push(step({
82
+ kind: "createCatalog",
83
+ classification: "additive",
84
+ description: "Create the runtime's OAuth tables, which connect agents to the MCP server.",
85
+ sql: OAUTH_TABLES_DDL,
86
+ }));
87
+ }
88
+ if (lacks(AGENT_TABLES)) {
89
+ draft.runtimeTables.push(step({
90
+ kind: "createCatalog",
91
+ classification: "additive",
92
+ description: "Create the runtime's agent tables, which pair channel accounts with members.",
93
+ sql: AGENT_TABLES_DDL,
94
+ }));
95
+ }
84
96
  }
85
97
  if (storedTargets.some((entity) => entity.properties.some((property) => property.type === "blob")) &&
86
98
  !current?.entities.some((entity) => entity.properties.some((property) => property.type === "blob"))) {
@@ -164,6 +164,18 @@ export declare const OAUTH_TOKENS_TABLE = "_oauth_tokens";
164
164
  export declare const OAUTH_TABLES: ReadonlyArray<string>;
165
165
  /** The OAuth tables of the MCP server's authorization server. Every statement is idempotent. */
166
166
  export declare const OAUTH_TABLES_DDL: ReadonlyArray<string>;
167
+ /** Channel accounts waiting, for ten minutes, for a member to claim them with a short code. */
168
+ export declare const AGENT_PAIRINGS_TABLE = "_agent_pairings";
169
+ /** Channel accounts paired with members: the app's agent acts as the member for their messages. */
170
+ export declare const AGENT_IDENTITIES_TABLE = "_agent_identities";
171
+ /** The agent's tables, in the order they are created. */
172
+ export declare const AGENT_TABLES: ReadonlyArray<string>;
173
+ /**
174
+ * The tables of the app's agent: pairing codes, stored only as SHA-256 hashes, and the identities
175
+ * they create. An account and a member each have at most one live identity per provider. Every
176
+ * statement is idempotent.
177
+ */
178
+ export declare const AGENT_TABLES_DDL: ReadonlyArray<string>;
167
179
  /**
168
180
  * The runtime's own tables beside the catalog. They are created with it, after the entity tables
169
181
  * because they refer to the built-in user table, and every statement is idempotent.
@@ -524,6 +524,53 @@ export const OAUTH_TABLES_DDL = [
524
524
  `CREATE INDEX IF NOT EXISTS "${OAUTH_TOKENS_TABLE}_grant_id_idx" ` +
525
525
  `ON ${quoteIdentifier(OAUTH_TOKENS_TABLE)} ("grant_id")`,
526
526
  ];
527
+ /** Channel accounts waiting, for ten minutes, for a member to claim them with a short code. */
528
+ export const AGENT_PAIRINGS_TABLE = "_agent_pairings";
529
+ /** Channel accounts paired with members: the app's agent acts as the member for their messages. */
530
+ export const AGENT_IDENTITIES_TABLE = "_agent_identities";
531
+ /** The agent's tables, in the order they are created. */
532
+ export const AGENT_TABLES = [AGENT_PAIRINGS_TABLE, AGENT_IDENTITIES_TABLE];
533
+ /**
534
+ * The tables of the app's agent: pairing codes, stored only as SHA-256 hashes, and the identities
535
+ * they create. An account and a member each have at most one live identity per provider. Every
536
+ * statement is idempotent.
537
+ */
538
+ export const AGENT_TABLES_DDL = [
539
+ [
540
+ `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(AGENT_PAIRINGS_TABLE)} (`,
541
+ ` "id" uuid NOT NULL,`,
542
+ ` "code_hash" text NOT NULL,`,
543
+ ` "provider" text NOT NULL,`,
544
+ ` "external_id" text NOT NULL,`,
545
+ ` "display" text,`,
546
+ ` "created_at" timestamptz NOT NULL DEFAULT now(),`,
547
+ ` "expires_at" timestamptz NOT NULL,`,
548
+ ` "consumed_at" timestamptz,`,
549
+ ` CONSTRAINT "${AGENT_PAIRINGS_TABLE}_pkey" PRIMARY KEY ("id"),`,
550
+ ` CONSTRAINT "${AGENT_PAIRINGS_TABLE}_code_hash_key" UNIQUE ("code_hash")`,
551
+ `)`,
552
+ ].join("\n"),
553
+ [
554
+ `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(AGENT_IDENTITIES_TABLE)} (`,
555
+ ` "id" uuid NOT NULL,`,
556
+ ` "provider" text NOT NULL,`,
557
+ ` "external_id" text NOT NULL,`,
558
+ ` "user_id" uuid NOT NULL,`,
559
+ ` "display" text,`,
560
+ ` "created_at" timestamptz NOT NULL DEFAULT now(),`,
561
+ ` "revoked_at" timestamptz,`,
562
+ ` CONSTRAINT "${AGENT_IDENTITIES_TABLE}_pkey" PRIMARY KEY ("id"),`,
563
+ ` CONSTRAINT "${AGENT_IDENTITIES_TABLE}_user_id_fkey" FOREIGN KEY ("user_id") ` +
564
+ `REFERENCES ${quoteIdentifier(USER_TABLE)} ("id") ON DELETE CASCADE`,
565
+ `)`,
566
+ ].join("\n"),
567
+ `CREATE UNIQUE INDEX IF NOT EXISTS "${AGENT_IDENTITIES_TABLE}_account_key" ` +
568
+ `ON ${quoteIdentifier(AGENT_IDENTITIES_TABLE)} ("provider", "external_id") ` +
569
+ `WHERE "revoked_at" IS NULL`,
570
+ `CREATE UNIQUE INDEX IF NOT EXISTS "${AGENT_IDENTITIES_TABLE}_member_key" ` +
571
+ `ON ${quoteIdentifier(AGENT_IDENTITIES_TABLE)} ("user_id", "provider") ` +
572
+ `WHERE "revoked_at" IS NULL`,
573
+ ];
527
574
  /**
528
575
  * The runtime's own tables beside the catalog. They are created with it, after the entity tables
529
576
  * because they refer to the built-in user table, and every statement is idempotent.
@@ -556,6 +603,7 @@ export const RUNTIME_TABLES_DDL = [
556
603
  ].join("\n"),
557
604
  `CREATE INDEX IF NOT EXISTS "${API_KEYS_TABLE}_user_id_idx" ON ${quoteIdentifier(API_KEYS_TABLE)} ("user_id")`,
558
605
  ...OAUTH_TABLES_DDL,
606
+ ...AGENT_TABLES_DDL,
559
607
  ];
560
608
  export function columnDefinition(column) {
561
609
  const parts = [quoteIdentifier(column.name), column.type];
package/dist/names.d.ts CHANGED
@@ -23,7 +23,7 @@ export declare const RESERVED_MEMBER_NAMES: readonly ["and", "or", "not"];
23
23
  /** The ontology service's route for aggregates across entities: `/api/ontology/aggregate`. */
24
24
  export declare const AGGREGATE_ROUTE = "aggregate";
25
25
  /** Routes of the ontology service under `/api/ontology/`, which an entity's routes would shadow. */
26
- export declare const RESERVED_ENTITY_NAMES: readonly ["aggregate", "graph", "status", "describe", "openapi", "docs", "mcp", "me", "oauth"];
26
+ export declare const RESERVED_ENTITY_NAMES: readonly ["aggregate", "graph", "status", "describe", "openapi", "docs", "mcp", "me", "oauth", "agent"];
27
27
  /** The MCP tools of every stored entity's reads: `lead_find`, `lead_get` and `lead_history`. */
28
28
  export declare const MCP_READ_TOOLS: readonly ["find", "get", "history"];
29
29
  /** The MCP tools of the ontology as a whole. No entity tool can take them: they have no underscore. */
package/dist/names.js CHANGED
@@ -61,6 +61,7 @@ export const RESERVED_ENTITY_NAMES = [
61
61
  "mcp",
62
62
  "me",
63
63
  "oauth",
64
+ "agent",
64
65
  ];
65
66
  /** The MCP tools of every stored entity's reads: `lead_find`, `lead_get` and `lead_history`. */
66
67
  export const MCP_READ_TOOLS = ["find", "get", "history"];
@@ -0,0 +1,9 @@
1
+ import { SqlClient } from "effect/unstable/sql";
2
+ import type { Ontology } from "../ontology.js";
3
+ import type { AgentStore } from "../service/runtime-contract.js";
4
+ /**
5
+ * The agent store on the runtime's agent tables, as `ontology_app`. Expiry is the database's
6
+ * `now()`; a pairing is consumed with one `UPDATE … WHERE "consumed_at" IS NULL`, so two concurrent
7
+ * approvals cannot both succeed. Ids that are not UUIDs find nothing rather than fail the cast.
8
+ */
9
+ export declare function sqlAgentStore(ontology: Ontology, sql: SqlClient.SqlClient): AgentStore;
@@ -0,0 +1,145 @@
1
+ import { Effect } from "effect";
2
+ import { SqlClient } from "effect/unstable/sql";
3
+ import { AGENT_IDENTITIES_TABLE, AGENT_PAIRINGS_TABLE, USER_TABLE } from "../migrate/storage.js";
4
+ import { resolveMemberById } from "./principals.js";
5
+ import { isRecordId } from "./records.js";
6
+ const PAIRINGS = `"${AGENT_PAIRINGS_TABLE}"`;
7
+ const IDENTITIES = `"${AGENT_IDENTITIES_TABLE}"`;
8
+ const USERS = `"${USER_TABLE}"`;
9
+ /** A timestamp as epoch milliseconds, which every driver returns as a plain number. */
10
+ function epochMs(column) {
11
+ return `(extract(epoch from ${column}) * 1000)::double precision`;
12
+ }
13
+ const PAIRING_COLUMNS = `"id"::text AS "id", "provider", "external_id", "display", ` +
14
+ `${epochMs(`"expires_at"`)} AS "expires_at"`;
15
+ const IDENTITY_COLUMNS = `"id"::text AS "id", "provider", "external_id", "user_id"::text AS "user_id", "display", ` +
16
+ `${epochMs(`"created_at"`)} AS "created_at", ${epochMs(`"revoked_at"`)} AS "revoked_at"`;
17
+ function nullableText(value) {
18
+ return value === null || value === undefined ? null : String(value);
19
+ }
20
+ function pairingOf(row) {
21
+ return {
22
+ id: String(row.id),
23
+ provider: String(row.provider),
24
+ externalId: String(row.external_id),
25
+ display: nullableText(row.display),
26
+ expiresAt: new Date(Number(row.expires_at)),
27
+ };
28
+ }
29
+ function identityOf(row) {
30
+ return {
31
+ id: String(row.id),
32
+ provider: String(row.provider),
33
+ externalId: String(row.external_id),
34
+ userId: String(row.user_id),
35
+ display: nullableText(row.display),
36
+ createdAt: new Date(Number(row.created_at)),
37
+ revokedAt: row.revoked_at === null ? null : new Date(Number(row.revoked_at)),
38
+ };
39
+ }
40
+ /**
41
+ * The agent store on the runtime's agent tables, as `ontology_app`. Expiry is the database's
42
+ * `now()`; a pairing is consumed with one `UPDATE … WHERE "consumed_at" IS NULL`, so two concurrent
43
+ * approvals cannot both succeed. Ids that are not UUIDs find nothing rather than fail the cast.
44
+ */
45
+ export function sqlAgentStore(ontology, sql) {
46
+ const rows = (statement, values = []) => sql.unsafe(statement, values).pipe(Effect.orDie);
47
+ const memberPrincipal = (userId) => resolveMemberById(ontology, userId).pipe(Effect.provideService(SqlClient.SqlClient, sql));
48
+ return {
49
+ savePairing: (pairing) => sql
50
+ .withTransaction(Effect.gen(function* () {
51
+ yield* rows(`DELETE FROM ${PAIRINGS} WHERE "expires_at" <= now()`);
52
+ const inserted = yield* rows([
53
+ `INSERT INTO ${PAIRINGS}`,
54
+ ` ("id", "code_hash", "provider", "external_id", "display", "expires_at")`,
55
+ `VALUES ($1::uuid, $2, $3, $4, $5, $6::timestamptz)`,
56
+ `ON CONFLICT ("code_hash") DO NOTHING`,
57
+ `RETURNING "id"`,
58
+ ].join("\n"), [
59
+ pairing.id,
60
+ pairing.codeHash,
61
+ pairing.provider,
62
+ pairing.externalId,
63
+ pairing.display,
64
+ pairing.expiresAt.toISOString(),
65
+ ]);
66
+ return inserted.length > 0;
67
+ }))
68
+ .pipe(Effect.orDie),
69
+ findPairing: (codeHash) => rows([
70
+ `SELECT ${PAIRING_COLUMNS}`,
71
+ `FROM ${PAIRINGS}`,
72
+ `WHERE "code_hash" = $1 AND "consumed_at" IS NULL AND "expires_at" > now()`,
73
+ ].join("\n"), [codeHash]).pipe(Effect.map(([row]) => (row === undefined ? undefined : pairingOf(row)))),
74
+ approvePairing: ({ codeHash, userId, identityId }) => sql
75
+ .withTransaction(Effect.gen(function* () {
76
+ const [consumed] = yield* rows([
77
+ `UPDATE ${PAIRINGS} SET "consumed_at" = now()`,
78
+ `WHERE "code_hash" = $1 AND "consumed_at" IS NULL AND "expires_at" > now()`,
79
+ `RETURNING ${PAIRING_COLUMNS}`,
80
+ ].join("\n"), [codeHash]);
81
+ if (consumed === undefined) {
82
+ return undefined;
83
+ }
84
+ const pairing = pairingOf(consumed);
85
+ // Two approvals that replace the same account's or the same member's identity run one
86
+ // after the other, so the partial unique indexes never refuse a valid one.
87
+ yield* rows(`SELECT pg_advisory_xact_lock(hashtext($1)), pg_advisory_xact_lock(hashtext($2))`, [
88
+ `account:${pairing.provider}:${pairing.externalId}`,
89
+ `member:${userId}:${pairing.provider}`,
90
+ ]);
91
+ yield* rows([
92
+ `UPDATE ${IDENTITIES} SET "revoked_at" = now()`,
93
+ `WHERE "revoked_at" IS NULL AND "provider" = $1`,
94
+ ` AND ("external_id" = $2 OR "user_id" = $3::uuid)`,
95
+ ].join("\n"), [pairing.provider, pairing.externalId, userId]);
96
+ const [created] = yield* rows([
97
+ `INSERT INTO ${IDENTITIES} ("id", "provider", "external_id", "user_id", "display")`,
98
+ `VALUES ($1::uuid, $2, $3, $4::uuid, $5)`,
99
+ `RETURNING ${IDENTITY_COLUMNS}`,
100
+ ].join("\n"), [identityId, pairing.provider, pairing.externalId, userId, pairing.display]);
101
+ return created === undefined ? undefined : identityOf(created);
102
+ }))
103
+ .pipe(Effect.orDie),
104
+ findIdentity: ({ provider, externalId }) => rows([
105
+ `SELECT ${IDENTITY_COLUMNS}`,
106
+ `FROM ${IDENTITIES}`,
107
+ `WHERE "provider" = $1 AND "external_id" = $2`,
108
+ `ORDER BY "revoked_at" IS NOT NULL, "revoked_at" DESC`,
109
+ `LIMIT 1`,
110
+ ].join("\n"), [provider, externalId]).pipe(Effect.map(([row]) => (row === undefined ? undefined : identityOf(row)))),
111
+ listIdentities: (userId) => isRecordId(userId)
112
+ ? rows([
113
+ `SELECT ${IDENTITY_COLUMNS}`,
114
+ `FROM ${IDENTITIES}`,
115
+ `WHERE "user_id" = $1::uuid AND "revoked_at" IS NULL`,
116
+ `ORDER BY "created_at", "id"`,
117
+ ].join("\n"), [userId]).pipe(Effect.map((found) => found.map(identityOf)))
118
+ : Effect.succeed([]),
119
+ revokeIdentity: ({ id, userId }) => isRecordId(id) && isRecordId(userId)
120
+ ? rows([
121
+ `UPDATE ${IDENTITIES} SET "revoked_at" = now()`,
122
+ `WHERE "id" = $1::uuid AND "user_id" = $2::uuid AND "revoked_at" IS NULL`,
123
+ `RETURNING "id"`,
124
+ ].join("\n"), [id, userId]).pipe(Effect.map((revoked) => revoked.length > 0))
125
+ : Effect.succeed(false),
126
+ member: (userId) => Effect.gen(function* () {
127
+ if (!isRecordId(userId)) {
128
+ return undefined;
129
+ }
130
+ const principal = yield* memberPrincipal(userId);
131
+ if (principal === undefined) {
132
+ return undefined;
133
+ }
134
+ const [user] = yield* rows(`SELECT "name" FROM ${USERS} WHERE "id" = $1::uuid`, [userId]);
135
+ return { name: String(user?.name ?? ""), principal };
136
+ }),
137
+ actingMember: (userId) => Effect.gen(function* () {
138
+ if (!isRecordId(userId)) {
139
+ return undefined;
140
+ }
141
+ const [paired] = yield* rows(`SELECT 1 FROM ${IDENTITIES} WHERE "user_id" = $1::uuid AND "revoked_at" IS NULL LIMIT 1`, [userId]);
142
+ return paired === undefined ? undefined : yield* memberPrincipal(userId);
143
+ }),
144
+ };
145
+ }
@@ -6,6 +6,7 @@ import { maintainBlobStorage } from "../storage/maintenance.js";
6
6
  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
+ import { sqlAgentStore } from "./agents.js";
9
10
  import { generatedActions } from "./generated-actions.js";
10
11
  import { sqlOAuthStore } from "./oauth.js";
11
12
  import { runAction } from "./pipeline.js";
@@ -69,6 +70,7 @@ export function backendLayer(ontology, blobStorage) {
69
70
  return state?.enabled === true;
70
71
  }).pipe(Effect.orDie),
71
72
  oauth: sqlOAuthStore(ontology, sql),
73
+ agents: sqlAgentStore(ontology, sql),
72
74
  });
73
75
  }));
74
76
  }
@@ -0,0 +1,47 @@
1
+ import { Schema } from "effect";
2
+ import type { Principal } from "../../runtime.js";
3
+ import type { AgentIdentity } from "../runtime-contract.js";
4
+ /** How long a member has to open the connect link, sign in and approve the pairing. */
5
+ export declare const PAIRING_TTL_MS: number;
6
+ /**
7
+ * The letters and digits a pairing code uses: no 0/O, 1/I/L, so that a code read aloud or typed from
8
+ * a phone comes out right. 31 symbols, 8 of them: about 40 bits, against ten minutes of guessing by
9
+ * signed-in members only.
10
+ */
11
+ export declare const PAIRING_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
12
+ export declare const PAIRING_CODE_LENGTH = 8;
13
+ /** A new pairing code, from the operating system's random source without modulo bias. */
14
+ export declare function newPairingCode(): string;
15
+ /**
16
+ * A code as the member's browser sends it, in the canonical form it was hashed in: upper case,
17
+ * without the spaces or dashes someone may type between groups. `undefined` when it cannot be one.
18
+ */
19
+ export declare function canonicalPairingCode(value: string): string | undefined;
20
+ /** A channel's name: `telegram`, `slack`. It is part of a URL path and of every identity. */
21
+ export declare const Provider: Schema.String;
22
+ /** `POST /api/ontology/agent/pairings`: the account an unknown sender writes from. */
23
+ export declare const PairingRequest: Schema.Struct<{
24
+ readonly provider: Schema.String;
25
+ readonly external_id: Schema.String;
26
+ readonly display: Schema.optionalKey<Schema.NullOr<Schema.String>>;
27
+ }>;
28
+ export type PairingRequest = typeof PairingRequest.Type;
29
+ /** An identity on the wire, as the member's connect page and settings show it. */
30
+ export declare function identityForWire(identity: AgentIdentity): {
31
+ id: string;
32
+ provider: string;
33
+ external_id: string;
34
+ display: string | null;
35
+ created_at: string;
36
+ };
37
+ /** The member the agent acts for, as `GET …/identities/{provider}/{external_id}` answers. */
38
+ export declare function memberForWire(input: {
39
+ readonly name: string;
40
+ readonly principal: Principal;
41
+ }): {
42
+ user: {
43
+ id: string;
44
+ name: string;
45
+ };
46
+ roles: readonly string[];
47
+ };
@@ -0,0 +1,59 @@
1
+ import { randomInt } from "node:crypto";
2
+ import { Schema } from "effect";
3
+ /** How long a member has to open the connect link, sign in and approve the pairing. */
4
+ export const PAIRING_TTL_MS = 10 * 60 * 1000;
5
+ /**
6
+ * The letters and digits a pairing code uses: no 0/O, 1/I/L, so that a code read aloud or typed from
7
+ * a phone comes out right. 31 symbols, 8 of them: about 40 bits, against ten minutes of guessing by
8
+ * signed-in members only.
9
+ */
10
+ export const PAIRING_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
11
+ export const PAIRING_CODE_LENGTH = 8;
12
+ const PAIRING_CODE = new RegExp(`^[${PAIRING_CODE_ALPHABET}]{${PAIRING_CODE_LENGTH}}$`);
13
+ /** A new pairing code, from the operating system's random source without modulo bias. */
14
+ export function newPairingCode() {
15
+ let code = "";
16
+ for (let index = 0; index < PAIRING_CODE_LENGTH; index++) {
17
+ code += PAIRING_CODE_ALPHABET[randomInt(PAIRING_CODE_ALPHABET.length)];
18
+ }
19
+ return code;
20
+ }
21
+ /**
22
+ * A code as the member's browser sends it, in the canonical form it was hashed in: upper case,
23
+ * without the spaces or dashes someone may type between groups. `undefined` when it cannot be one.
24
+ */
25
+ export function canonicalPairingCode(value) {
26
+ const code = value.replace(/[\s-]/g, "").toUpperCase();
27
+ return PAIRING_CODE.test(code) ? code : undefined;
28
+ }
29
+ const MAX_EXTERNAL_ID_LENGTH = 256;
30
+ const MAX_DISPLAY_LENGTH = 200;
31
+ /** A channel's name: `telegram`, `slack`. It is part of a URL path and of every identity. */
32
+ export const Provider = Schema.String.check(Schema.isPattern(/^[a-z][a-z0-9_-]{0,31}$/)).annotate({
33
+ description: "The channel, such as telegram: lower case letters, digits, - and _.",
34
+ });
35
+ /** `POST /api/ontology/agent/pairings`: the account an unknown sender writes from. */
36
+ export const PairingRequest = Schema.Struct({
37
+ provider: Provider,
38
+ external_id: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(MAX_EXTERNAL_ID_LENGTH)).annotate({ description: "The account's id on the channel." }),
39
+ display: Schema.optionalKey(Schema.NullOr(Schema.String.check(Schema.isMaxLength(MAX_DISPLAY_LENGTH))).annotate({
40
+ description: "How the channel names the account, shown to the member who approves.",
41
+ })),
42
+ });
43
+ /** An identity on the wire, as the member's connect page and settings show it. */
44
+ export function identityForWire(identity) {
45
+ return {
46
+ id: identity.id,
47
+ provider: identity.provider,
48
+ external_id: identity.externalId,
49
+ display: identity.display,
50
+ created_at: identity.createdAt.toISOString(),
51
+ };
52
+ }
53
+ /** The member the agent acts for, as `GET …/identities/{provider}/{external_id}` answers. */
54
+ export function memberForWire(input) {
55
+ return {
56
+ user: { id: input.principal.id, name: input.name },
57
+ roles: input.principal.roles,
58
+ };
59
+ }
@@ -0,0 +1,27 @@
1
+ import { Layer } from "effect";
2
+ import { HttpRouter } from "effect/unstable/http";
3
+ import { type SessionExchange, type TokenVerifier } from "../authentication.js";
4
+ import { RequestId } from "../errors.js";
5
+ import { type RateLimit } from "../rate-limit.js";
6
+ import { OntologyBackend } from "../runtime-contract.js";
7
+ import { type RuntimeTokenVerifier } from "../runtime-identity.js";
8
+ /** The agent's endpoints, under the ontology service's path. */
9
+ export declare const AGENT_PATH = "/api/ontology/agent";
10
+ /**
11
+ * Pairings one client address may start per minute; each one is a row in the database. The agent's
12
+ * functions share a few egress addresses, so the limit leaves room for a busy channel.
13
+ */
14
+ export declare const PAIRING_LIMIT: RateLimit;
15
+ export interface AgentServerOptions {
16
+ readonly verifyToken: TokenVerifier;
17
+ /** The deployment's runtime tokens; `undefined` while runtime authentication is disabled. */
18
+ readonly verifyRuntime: RuntimeTokenVerifier | undefined;
19
+ readonly exchangeSession: SessionExchange | undefined;
20
+ }
21
+ /**
22
+ * The routes that pair channel accounts with members for the app's own agent. Runtime routes take
23
+ * only the deployment's runtime token. Member routes take a member's Neon Auth session or token, and
24
+ * refuse API keys and the runtime, even acting as a member: only the member pairs or unpairs.
25
+ * Without the agent tables, it serves nothing.
26
+ */
27
+ export declare function agentLayer(options: AgentServerOptions): Layer.Layer<never, never, OntologyBackend | HttpRouter.HttpRouter | HttpRouter.Request<"Requires", RequestId>>;