@segmentstream-cloud/ontology 0.9.0 → 0.11.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 (90) hide show
  1. package/dist/action.d.ts +19 -0
  2. package/dist/action.js +1 -0
  3. package/dist/analyze.d.ts +3 -1
  4. package/dist/analyze.js +97 -2
  5. package/dist/bin/migrate.js +3 -2
  6. package/dist/blob.d.ts +39 -0
  7. package/dist/blob.js +49 -0
  8. package/dist/diagnostics.d.ts +3 -3
  9. package/dist/diagnostics.js +1 -0
  10. package/dist/entity.d.ts +5 -3
  11. package/dist/index.d.ts +9 -4
  12. package/dist/index.js +6 -1
  13. package/dist/manifest.d.ts +71 -7
  14. package/dist/manifest.js +18 -1
  15. package/dist/migrate/apply.d.ts +7 -0
  16. package/dist/migrate/apply.js +18 -1
  17. package/dist/migrate/index.d.ts +1 -1
  18. package/dist/migrate/index.js +1 -1
  19. package/dist/migrate/model.d.ts +15 -2
  20. package/dist/migrate/plan.d.ts +6 -0
  21. package/dist/migrate/plan.js +35 -3
  22. package/dist/migrate/storage.d.ts +14 -0
  23. package/dist/migrate/storage.js +95 -0
  24. package/dist/names.d.ts +9 -1
  25. package/dist/names.js +11 -0
  26. package/dist/nitro.d.ts +13 -0
  27. package/dist/nitro.js +18 -0
  28. package/dist/property.d.ts +10 -1
  29. package/dist/property.js +12 -1
  30. package/dist/query.js +6 -1
  31. package/dist/runtime/generated-actions.d.ts +3 -0
  32. package/dist/runtime/generated-actions.js +10 -5
  33. package/dist/runtime/history.d.ts +3 -0
  34. package/dist/runtime/oauth.d.ts +9 -0
  35. package/dist/runtime/oauth.js +165 -0
  36. package/dist/runtime/pipeline.d.ts +5 -1
  37. package/dist/runtime/pipeline.js +87 -3
  38. package/dist/runtime/principals.d.ts +5 -0
  39. package/dist/runtime/principals.js +15 -0
  40. package/dist/runtime/records.js +9 -0
  41. package/dist/runtime/service-binding.d.ts +2 -1
  42. package/dist/runtime/service-binding.js +15 -2
  43. package/dist/runtime/writes.js +17 -3
  44. package/dist/service/api.js +19 -10
  45. package/dist/service/authentication.d.ts +27 -1
  46. package/dist/service/authentication.js +48 -26
  47. package/dist/service/config.d.ts +8 -0
  48. package/dist/service/config.js +18 -0
  49. package/dist/service/descriptions.d.ts +8 -0
  50. package/dist/service/descriptions.js +15 -0
  51. package/dist/service/errors.d.ts +5 -1
  52. package/dist/service/errors.js +6 -2
  53. package/dist/service/handlers.d.ts +1 -4
  54. package/dist/service/handlers.js +59 -203
  55. package/dist/service/mcp/server.d.ts +24 -0
  56. package/dist/service/mcp/server.js +370 -0
  57. package/dist/service/mcp/tools.d.ts +47 -0
  58. package/dist/service/mcp/tools.js +218 -0
  59. package/dist/service/oauth/protocol.d.ts +52 -0
  60. package/dist/service/oauth/protocol.js +110 -0
  61. package/dist/service/oauth/server.d.ts +27 -0
  62. package/dist/service/oauth/server.js +576 -0
  63. package/dist/service/operations.d.ts +114 -0
  64. package/dist/service/operations.js +218 -0
  65. package/dist/service/runtime-contract.d.ts +98 -0
  66. package/dist/service/schemas.d.ts +18 -2
  67. package/dist/service/schemas.js +18 -2
  68. package/dist/service/service.d.ts +3 -3
  69. package/dist/service/service.js +27 -7
  70. package/dist/storage/api.d.ts +4 -0
  71. package/dist/storage/api.js +39 -0
  72. package/dist/storage/authorization.d.ts +8 -0
  73. package/dist/storage/authorization.js +30 -0
  74. package/dist/storage/maintenance-auth.d.ts +20 -0
  75. package/dist/storage/maintenance-auth.js +24 -0
  76. package/dist/storage/maintenance.d.ts +7 -0
  77. package/dist/storage/maintenance.js +50 -0
  78. package/dist/storage/neon.d.ts +3 -0
  79. package/dist/storage/neon.js +212 -0
  80. package/dist/storage/operator.d.ts +10 -0
  81. package/dist/storage/operator.js +30 -0
  82. package/dist/storage/provider.d.ts +80 -0
  83. package/dist/storage/provider.js +20 -0
  84. package/dist/storage/sessions.d.ts +87 -0
  85. package/dist/storage/sessions.js +341 -0
  86. package/dist/storage/tables.d.ts +2 -0
  87. package/dist/storage/tables.js +55 -0
  88. package/dist/storage/writes.d.ts +7 -0
  89. package/dist/storage/writes.js +88 -0
  90. package/package.json +8 -1
@@ -29,6 +29,7 @@ export const COLUMN_TYPES = {
29
29
  date: "date",
30
30
  timestamp: "timestamptz",
31
31
  json: "jsonb",
32
+ blob: "uuid",
32
33
  };
33
34
  const ON_DELETE_ACTIONS = {
34
35
  setNull: "SET NULL",
@@ -198,6 +199,15 @@ function foreignKeyDefinition(input) {
198
199
  `REFERENCES ${quoteIdentifier(input.references)} (${quoteIdentifier(SYSTEM_COLUMNS.id)})`;
199
200
  return input.onDelete === undefined ? reference : `${reference} ON DELETE ${input.onDelete}`;
200
201
  }
202
+ export function blobForeignKey(entity, property) {
203
+ const column = columnName(property.name);
204
+ return {
205
+ name: foreignKeyName(entity, column),
206
+ kind: "foreignKey",
207
+ definition: foreignKeyDefinition({ column, references: "_blobs" }),
208
+ references: "_blobs",
209
+ };
210
+ }
201
211
  function userReference(table, column) {
202
212
  return {
203
213
  name: foreignKeyName(table, column),
@@ -389,6 +399,9 @@ export function entityStorage(entity) {
389
399
  userReference(table, SYSTEM_COLUMNS.created_by),
390
400
  userReference(table, SYSTEM_COLUMNS.updated_by),
391
401
  ...checks,
402
+ ...entity.properties
403
+ .filter((property) => property.type === "blob")
404
+ .map((property) => blobForeignKey(entity.name, property)),
392
405
  ...ones.map((relationship) => relationshipForeignKey(entity.name, relationship)),
393
406
  ],
394
407
  indexes: entityIndexes(entity),
@@ -420,6 +433,87 @@ export const USER_ROLES_TABLE = "_user_roles";
420
433
  * secret only as a salted PBKDF2 hash.
421
434
  */
422
435
  export const API_KEYS_TABLE = "_api_keys";
436
+ /** OAuth clients that registered themselves to connect agents to the MCP server. */
437
+ export const OAUTH_CLIENTS_TABLE = "_oauth_clients";
438
+ /** Authorization requests waiting for a member's consent, for a few minutes. */
439
+ export const OAUTH_REQUESTS_TABLE = "_oauth_requests";
440
+ /** What a member allowed a client to do: revoking a grant stops its tokens. */
441
+ export const OAUTH_GRANTS_TABLE = "_oauth_grants";
442
+ /** Authorization codes, access tokens and refresh tokens, stored only as SHA-256 hashes. */
443
+ export const OAUTH_TOKENS_TABLE = "_oauth_tokens";
444
+ /** The OAuth tables, in the order they are created. */
445
+ export const OAUTH_TABLES = [
446
+ OAUTH_CLIENTS_TABLE,
447
+ OAUTH_REQUESTS_TABLE,
448
+ OAUTH_GRANTS_TABLE,
449
+ OAUTH_TOKENS_TABLE,
450
+ ];
451
+ /** The OAuth tables of the MCP server's authorization server. Every statement is idempotent. */
452
+ export const OAUTH_TABLES_DDL = [
453
+ [
454
+ `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(OAUTH_CLIENTS_TABLE)} (`,
455
+ ` "id" text NOT NULL,`,
456
+ ` "name" text NOT NULL,`,
457
+ ` "redirect_uris" jsonb NOT NULL,`,
458
+ ` "auth_method" text NOT NULL,`,
459
+ ` "secret_hash" text,`,
460
+ ` "client_uri" text,`,
461
+ ` "created_at" timestamptz NOT NULL DEFAULT now(),`,
462
+ ` CONSTRAINT "${OAUTH_CLIENTS_TABLE}_pkey" PRIMARY KEY ("id")`,
463
+ `)`,
464
+ ].join("\n"),
465
+ [
466
+ `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(OAUTH_REQUESTS_TABLE)} (`,
467
+ ` "id" text NOT NULL,`,
468
+ ` "client_id" text NOT NULL,`,
469
+ ` "redirect_uri" text NOT NULL,`,
470
+ ` "state" text,`,
471
+ ` "code_challenge" text NOT NULL,`,
472
+ ` "scope" text NOT NULL,`,
473
+ ` "resource" text NOT NULL,`,
474
+ ` "expires_at" timestamptz NOT NULL,`,
475
+ ` CONSTRAINT "${OAUTH_REQUESTS_TABLE}_pkey" PRIMARY KEY ("id"),`,
476
+ ` CONSTRAINT "${OAUTH_REQUESTS_TABLE}_client_id_fkey" FOREIGN KEY ("client_id") ` +
477
+ `REFERENCES ${quoteIdentifier(OAUTH_CLIENTS_TABLE)} ("id") ON DELETE CASCADE`,
478
+ `)`,
479
+ ].join("\n"),
480
+ [
481
+ `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(OAUTH_GRANTS_TABLE)} (`,
482
+ ` "id" uuid NOT NULL,`,
483
+ ` "client_id" text NOT NULL,`,
484
+ ` "user_id" uuid NOT NULL,`,
485
+ ` "scope" text NOT NULL,`,
486
+ ` "resource" text NOT NULL,`,
487
+ ` "created_at" timestamptz NOT NULL DEFAULT now(),`,
488
+ ` "revoked_at" timestamptz,`,
489
+ ` CONSTRAINT "${OAUTH_GRANTS_TABLE}_pkey" PRIMARY KEY ("id"),`,
490
+ ` CONSTRAINT "${OAUTH_GRANTS_TABLE}_client_id_fkey" FOREIGN KEY ("client_id") ` +
491
+ `REFERENCES ${quoteIdentifier(OAUTH_CLIENTS_TABLE)} ("id") ON DELETE CASCADE,`,
492
+ ` CONSTRAINT "${OAUTH_GRANTS_TABLE}_user_id_fkey" FOREIGN KEY ("user_id") ` +
493
+ `REFERENCES ${quoteIdentifier(USER_TABLE)} ("id") ON DELETE CASCADE`,
494
+ `)`,
495
+ ].join("\n"),
496
+ `CREATE INDEX IF NOT EXISTS "${OAUTH_GRANTS_TABLE}_user_id_idx" ` +
497
+ `ON ${quoteIdentifier(OAUTH_GRANTS_TABLE)} ("user_id")`,
498
+ [
499
+ `CREATE TABLE IF NOT EXISTS ${quoteIdentifier(OAUTH_TOKENS_TABLE)} (`,
500
+ ` "hash" text NOT NULL,`,
501
+ ` "grant_id" uuid NOT NULL,`,
502
+ ` "kind" text NOT NULL,`,
503
+ ` "redirect_uri" text,`,
504
+ ` "code_challenge" text,`,
505
+ ` "expires_at" timestamptz NOT NULL,`,
506
+ ` "used_at" timestamptz,`,
507
+ ` "created_at" timestamptz NOT NULL DEFAULT now(),`,
508
+ ` CONSTRAINT "${OAUTH_TOKENS_TABLE}_pkey" PRIMARY KEY ("hash"),`,
509
+ ` CONSTRAINT "${OAUTH_TOKENS_TABLE}_kind_check" CHECK ("kind" IN ('code', 'access', 'refresh')),`,
510
+ ` CONSTRAINT "${OAUTH_TOKENS_TABLE}_grant_id_fkey" FOREIGN KEY ("grant_id") ` +
511
+ `REFERENCES ${quoteIdentifier(OAUTH_GRANTS_TABLE)} ("id") ON DELETE CASCADE`,
512
+ `)`,
513
+ ].join("\n"),
514
+ `CREATE INDEX IF NOT EXISTS "${OAUTH_TOKENS_TABLE}_grant_id_idx" ` +
515
+ `ON ${quoteIdentifier(OAUTH_TOKENS_TABLE)} ("grant_id")`,
516
+ ];
423
517
  /**
424
518
  * The runtime's own tables beside the catalog. They are created with it, after the entity tables
425
519
  * because they refer to the built-in user table, and every statement is idempotent.
@@ -451,6 +545,7 @@ export const RUNTIME_TABLES_DDL = [
451
545
  `)`,
452
546
  ].join("\n"),
453
547
  `CREATE INDEX IF NOT EXISTS "${API_KEYS_TABLE}_user_id_idx" ON ${quoteIdentifier(API_KEYS_TABLE)} ("user_id")`,
548
+ ...OAUTH_TABLES_DDL,
454
549
  ];
455
550
  export function columnDefinition(column) {
456
551
  const parts = [quoteIdentifier(column.name), column.type];
package/dist/names.d.ts CHANGED
@@ -21,7 +21,15 @@ export declare const HISTORY_SUFFIX = "_history";
21
21
  /** The combinators of the query language, which take the place of field names in a `where`. */
22
22
  export declare const RESERVED_MEMBER_NAMES: readonly ["and", "or", "not"];
23
23
  /** Routes of the ontology service under `/api/ontology/`, which an entity's routes would shadow. */
24
- export declare const RESERVED_ENTITY_NAMES: readonly ["graph", "status", "describe", "openapi", "docs", "mcp", "me"];
24
+ export declare const RESERVED_ENTITY_NAMES: readonly ["graph", "status", "describe", "openapi", "docs", "mcp", "me", "oauth"];
25
+ /** The MCP tools of every stored entity's reads: `lead_find`, `lead_get` and `lead_history`. */
26
+ export declare const MCP_READ_TOOLS: readonly ["find", "get", "history"];
27
+ /** The MCP tools of the ontology as a whole. No entity tool can take them: they have no underscore. */
28
+ export declare const MCP_GLOBAL_TOOLS: readonly ["describe", "me"];
29
+ /** The longest tool name that Claude and OpenAI accept. */
30
+ export declare const MCP_TOOL_NAME_MAX_LENGTH = 64;
31
+ /** The MCP tool of an entity's read or action: `lead_find`, `lead_change_status`. */
32
+ export declare function mcpToolName(entity: string, operation: string): string;
25
33
  /** `/api/ontology/<entity>/{id}/history`, which a relationship's traversal would shadow. */
26
34
  export declare const RESERVED_RELATIONSHIP_NAMES: readonly ["history"];
27
35
  export declare function isSystemProperty(name: string): boolean;
package/dist/names.js CHANGED
@@ -57,7 +57,18 @@ export const RESERVED_ENTITY_NAMES = [
57
57
  "docs",
58
58
  "mcp",
59
59
  "me",
60
+ "oauth",
60
61
  ];
62
+ /** The MCP tools of every stored entity's reads: `lead_find`, `lead_get` and `lead_history`. */
63
+ export const MCP_READ_TOOLS = ["find", "get", "history"];
64
+ /** The MCP tools of the ontology as a whole. No entity tool can take them: they have no underscore. */
65
+ export const MCP_GLOBAL_TOOLS = ["describe", "me"];
66
+ /** The longest tool name that Claude and OpenAI accept. */
67
+ export const MCP_TOOL_NAME_MAX_LENGTH = 64;
68
+ /** The MCP tool of an entity's read or action: `lead_find`, `lead_change_status`. */
69
+ export function mcpToolName(entity, operation) {
70
+ return `${entity}_${operation}`;
71
+ }
61
72
  /** `/api/ontology/<entity>/{id}/history`, which a relationship's traversal would shadow. */
62
73
  export const RESERVED_RELATIONSHIP_NAMES = ["history"];
63
74
  export function isSystemProperty(name) {
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The paths the ontology service answers, for the workspace's `ontology/nitro.config.ts`. They come
3
+ * from the SDK, so a path the service adds arrives with an upgrade and the config stays as it is.
4
+ * This entry imports nothing: Nitro loads it while it builds.
5
+ */
6
+ export declare const ONTOLOGY_SERVICE_PATHS: readonly ["/api/ontology", "/api/ontology/**", "/mcp", "/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp", "/.well-known/oauth-authorization-server"];
7
+ /** A Nitro route to the service's handler, loaded on its first request. */
8
+ export interface OntologyRoute {
9
+ readonly handler: string;
10
+ readonly lazy: true;
11
+ }
12
+ /** The Nitro routes that send every path of the ontology service to one handler. */
13
+ export declare function ontologyRoutes(handler: string): Record<string, OntologyRoute>;
package/dist/nitro.js ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The paths the ontology service answers, for the workspace's `ontology/nitro.config.ts`. They come
3
+ * from the SDK, so a path the service adds arrives with an upgrade and the config stays as it is.
4
+ * This entry imports nothing: Nitro loads it while it builds.
5
+ */
6
+ export const ONTOLOGY_SERVICE_PATHS = [
7
+ "/api/ontology",
8
+ "/api/ontology/**",
9
+ "/mcp",
10
+ // The MCP server's OAuth metadata, on the app's origin.
11
+ "/.well-known/oauth-protected-resource",
12
+ "/.well-known/oauth-protected-resource/mcp",
13
+ "/.well-known/oauth-authorization-server",
14
+ ];
15
+ /** The Nitro routes that send every path of the ontology service to one handler. */
16
+ export function ontologyRoutes(handler) {
17
+ return Object.fromEntries(ONTOLOGY_SERVICE_PATHS.map((path) => [path, { handler, lazy: true }]));
18
+ }
@@ -1,6 +1,7 @@
1
1
  import { Schema } from "effect";
2
+ import { BlobValue, type BLOB_CONTENT_TYPES } from "./blob.js";
2
3
  /** The closed set of storage types. Each has a known column, JSON Schema, filters and rendering. */
3
- export declare const PROPERTY_TYPES: readonly ["text", "email", "url", "integer", "number", "boolean", "date", "timestamp", "select", "json"];
4
+ export declare const PROPERTY_TYPES: readonly ["text", "email", "url", "integer", "number", "boolean", "date", "timestamp", "select", "json", "blob"];
4
5
  export type PropertyType = (typeof PROPERTY_TYPES)[number];
5
6
  /** Schema annotation that carries a property's storage metadata. */
6
7
  export declare const PROPERTY_ANNOTATION = "segmentstream/ontology/property";
@@ -40,6 +41,8 @@ export interface PropertyDefinition {
40
41
  readonly max?: number;
41
42
  readonly maxLength?: number;
42
43
  readonly options?: ReadonlyArray<SelectOption>;
44
+ readonly accept?: ReadonlyArray<string>;
45
+ readonly maxBytes?: number;
43
46
  }
44
47
  /**
45
48
  * A property: an Effect Schema of its value, usable in action inputs, with its storage metadata in
@@ -127,6 +130,11 @@ declare function ref<const O extends ReferenceOptions>(target: string | {
127
130
  readonly name: string;
128
131
  }, options?: O): ReferenceOf<O>;
129
132
  /** Property constructors. */
133
+ export interface BlobOptions extends Omit<PropertyOptions<BlobValue>, "default" | "unique"> {
134
+ readonly accept: ReadonlyArray<(typeof BLOB_CONTENT_TYPES)[number]>;
135
+ readonly maxBytes?: number;
136
+ }
137
+ declare function blob<const O extends BlobOptions>(options: O): PropertyOf<BlobValue, O>;
130
138
  export declare const p: {
131
139
  readonly text: typeof text;
132
140
  readonly email: typeof email;
@@ -138,6 +146,7 @@ export declare const p: {
138
146
  readonly timestamp: typeof timestamp;
139
147
  readonly select: typeof select;
140
148
  readonly json: typeof json;
149
+ readonly blob: typeof blob;
141
150
  readonly ref: typeof ref;
142
151
  };
143
152
  /** The storage metadata of a schema built with a `p.*` constructor, or `undefined` for any other value. */
package/dist/property.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Schema } from "effect";
2
2
  import { humanize } from "./names.js";
3
+ import { BlobValue, MAX_BLOB_BYTES } from "./blob.js";
3
4
  import { nameToWire } from "./authoring-names.js";
4
5
  /** The closed set of storage types. Each has a known column, JSON Schema, filters and rendering. */
5
6
  export const PROPERTY_TYPES = [
@@ -13,6 +14,7 @@ export const PROPERTY_TYPES = [
13
14
  "timestamp",
14
15
  "select",
15
16
  "json",
17
+ "blob",
16
18
  ];
17
19
  /** Schema annotation that carries a property's storage metadata. */
18
20
  export const PROPERTY_ANNOTATION = "segmentstream/ontology/property";
@@ -47,6 +49,8 @@ function construct({ type, base, options, checks = [], format, selectOptions, })
47
49
  ...(options.min !== undefined ? { min: options.min } : {}),
48
50
  ...(options.max !== undefined ? { max: options.max } : {}),
49
51
  ...(options.maxLength !== undefined ? { maxLength: options.maxLength } : {}),
52
+ ...(options.accept !== undefined ? { accept: options.accept } : {}),
53
+ ...(options.maxBytes !== undefined ? { maxBytes: options.maxBytes } : {}),
50
54
  ...(selectOptions !== undefined ? { options: selectOptions } : {}),
51
55
  };
52
56
  // Annotations go on the base node, before the checks, so that the JSON Schema keeps them at the top
@@ -176,7 +180,13 @@ function ref(target, options) {
176
180
  }).check(Schema.isPattern(UUID));
177
181
  return (settings.optional === true ? Schema.optionalKey(schema) : schema);
178
182
  }
179
- /** Property constructors. */
183
+ function blob(options) {
184
+ return construct({
185
+ type: "blob",
186
+ base: BlobValue,
187
+ options: { ...options, maxBytes: options.maxBytes ?? MAX_BLOB_BYTES },
188
+ });
189
+ }
180
190
  export const p = {
181
191
  text,
182
192
  email,
@@ -188,6 +198,7 @@ export const p = {
188
198
  timestamp,
189
199
  select,
190
200
  json,
201
+ blob,
191
202
  ref,
192
203
  };
193
204
  function findAnnotation(ast, key) {
package/dist/query.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Schema } from "effect";
2
2
  import { wireStruct } from "./wire.js";
3
+ import { BlobValue } from "./blob.js";
3
4
  /**
4
5
  * The query language of `find` and of the HTTP API's list and query endpoints.
5
6
  *
@@ -194,12 +195,13 @@ export function fieldValueSchema(field) {
194
195
  case "json":
195
196
  return JsonValue;
196
197
  case "uuid":
198
+ case "blob":
197
199
  return UuidValue;
198
200
  }
199
201
  }
200
202
  /** Fields a query can sort on: every field but `json` properties. */
201
203
  export function isOrderable(field) {
202
- return field.storage !== "json";
204
+ return field.storage !== "json" && field.storage !== "blob";
203
205
  }
204
206
  const FILTER_IDENTIFIERS = {
205
207
  text: "TextFilter",
@@ -212,6 +214,7 @@ const FILTER_IDENTIFIERS = {
212
214
  timestamp: "TimestampFilter",
213
215
  json: "JsonFilter",
214
216
  uuid: "IdFilter",
217
+ blob: "IdFilter",
215
218
  };
216
219
  const FILTER_DESCRIPTIONS = {
217
220
  TextFilter: "contains and startsWith ignore case.",
@@ -413,6 +416,8 @@ function recordValueSchema(field) {
413
416
  return Schema.Literals(field.options ?? []);
414
417
  case "json":
415
418
  return Schema.Json;
419
+ case "blob":
420
+ return BlobValue;
416
421
  default:
417
422
  return Schema.String;
418
423
  }
@@ -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;
@@ -1,4 +1,5 @@
1
1
  import { Effect, Schema } from "effect";
2
+ import { uploadInput, BLOB_UPLOAD_ANNOTATION } from "../blob.js";
2
3
  import { action } from "../action.js";
3
4
  import { GENERATED_UPSERT_PREFIX } from "../names.js";
4
5
  import { generatedActionPermissions } from "../permissions.js";
@@ -16,7 +17,10 @@ export function upsertActionName(property) {
16
17
  return `${GENERATED_UPSERT_PREFIX}${property}`;
17
18
  }
18
19
  /** A property's value schema, refusing days that do not exist, which PostgreSQL would reject. */
19
- function propertyInput(field) {
20
+ function propertyInput(field, entity) {
21
+ if (field.storage === "blob") {
22
+ return uploadInput(entity.name, field.name);
23
+ }
20
24
  const schema = propertyValueSchema(field.property);
21
25
  if (field.storage === "date") {
22
26
  return schema.check(Schema.makeFilter((value) => isCalendarDate(value) || "Expected a date that exists"));
@@ -44,7 +48,7 @@ function valueFields(entity, { mode }) {
44
48
  if (mode === "update" && definition?.managed === true) {
45
49
  continue;
46
50
  }
47
- const value = propertyInput(field);
51
+ const value = propertyInput(field, entity);
48
52
  const required = mode === "create" &&
49
53
  !field.nullable &&
50
54
  !(definition !== undefined && "default" in definition);
@@ -162,9 +166,10 @@ function buildGeneratedActions(ontology, entityName) {
162
166
  }
163
167
  return actions;
164
168
  }
165
- 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) {
166
171
  const document = Schema.toJsonSchemaDocument(input, {
167
- includeAnnotationKey: (key) => key === REFERENCE_ANNOTATION,
172
+ includeAnnotationKey: (key) => key === REFERENCE_ANNOTATION || key === BLOB_UPLOAD_ANNOTATION,
168
173
  onExcessProperty: "error",
169
174
  });
170
175
  const schema = document.schema;
@@ -178,6 +183,6 @@ export function generatedActionManifests(ontology, entityName) {
178
183
  name,
179
184
  description: generated.description,
180
185
  permissions: generatedActionPermissions(entityName, name),
181
- input: inputJsonSchema(generated.input),
186
+ input: actionInputJsonSchema(generated.input),
182
187
  }));
183
188
  }
@@ -57,6 +57,9 @@ export interface HistoryScope {
57
57
  readonly action: string;
58
58
  readonly actionId: string;
59
59
  readonly principalId: string | null;
60
+ readonly branchId?: string;
61
+ readonly blobActionKey?: string;
62
+ readonly createIntent?: string;
60
63
  }
61
64
  /** `INSERT` of one history row; `at` is the transaction's time, shared by the whole action. */
62
65
  export declare function insertHistory(scope: HistoryScope, write: HistoryWrite, params: Params): string;
@@ -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
+ }
@@ -32,7 +32,10 @@ export interface ActionRequest<R = never> {
32
32
  readonly options?: {
33
33
  /** Decode the input and check the permission, without running `prepare` or the handler. */
34
34
  readonly validateOnly?: boolean;
35
+ readonly idempotencyKey?: string;
36
+ readonly createIntent?: string;
35
37
  };
38
+ readonly branchId?: string;
36
39
  readonly afterCommit?: ReadonlyArray<AfterCommit<R>>;
37
40
  /** The request's id, which `prepare` and the handler receive for logs; the action id by default. */
38
41
  readonly requestId?: string;
@@ -96,7 +99,8 @@ export declare function findAction(ontology: Ontology, entityName: string, actio
96
99
  * 1. decode the input with the action's schema; issues are returned, not raised;
97
100
  * 2. check that the principal's permissions cover the action's (PermissionDenied);
98
101
  * 3. with `validateOnly`, stop;
99
- * 4. run `prepare`, when the action has one, without a transaction: reads and the declared
102
+ * 4. reauthorize and replay a committed blob action, if its idempotency key matches;
103
+ * otherwise run `prepare`, when the action has one, without a transaction: reads and the declared
100
104
  * connections only, under {@link PREPARE_TIMEOUT};
101
105
  * 5. without a handler, answer with what `prepare` returned;
102
106
  * 6. open one transaction and run the handler with what `prepare` returned and the runtime, which