@segmentstream/ontology 0.0.0-stage → 0.92.1
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/LICENSE +201 -0
- package/NOTICE +2 -0
- package/README.md +1655 -2
- package/dist/action.d.ts +193 -0
- package/dist/action.js +41 -0
- package/dist/agent-chat.d.ts +44 -0
- package/dist/agent-chat.js +91 -0
- package/dist/agent-task-contract.d.ts +64 -0
- package/dist/agent-task-contract.js +15 -0
- package/dist/agent-task.d.ts +39 -0
- package/dist/agent-task.js +112 -0
- package/dist/analyze.d.ts +215 -0
- package/dist/analyze.js +2438 -0
- package/dist/app-feedback.d.ts +167 -0
- package/dist/app-feedback.js +176 -0
- package/dist/authoring-input.d.ts +3 -0
- package/dist/authoring-input.js +107 -0
- package/dist/authoring-names.d.ts +4 -0
- package/dist/authoring-names.js +13 -0
- package/dist/authoring-records.d.ts +35 -0
- package/dist/authoring-records.js +264 -0
- package/dist/authoring.d.ts +15 -0
- package/dist/authoring.js +331 -0
- package/dist/bin/check.d.ts +30 -0
- package/dist/bin/check.js +99 -0
- package/dist/bin/load.d.ts +48 -0
- package/dist/bin/load.js +139 -0
- package/dist/bin/main.d.ts +31 -0
- package/dist/bin/main.js +121 -0
- package/dist/bin/migrate.d.ts +57 -0
- package/dist/bin/migrate.js +170 -0
- package/dist/bin/output.d.ts +31 -0
- package/dist/bin/output.js +84 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +31 -0
- package/dist/blob.d.ts +52 -0
- package/dist/blob.js +87 -0
- package/dist/channel-session.d.ts +16 -0
- package/dist/channel-session.js +16 -0
- package/dist/chat.d.ts +43 -0
- package/dist/chat.js +34 -0
- package/dist/confirmation.d.ts +46 -0
- package/dist/confirmation.js +41 -0
- package/dist/connection.d.ts +213 -0
- package/dist/connection.js +289 -0
- package/dist/deployment.d.ts +35 -0
- package/dist/deployment.js +76 -0
- package/dist/diagnostics.d.ts +27 -0
- package/dist/diagnostics.js +106 -0
- package/dist/emails.d.ts +70 -0
- package/dist/emails.js +221 -0
- package/dist/entity.d.ts +331 -0
- package/dist/entity.js +153 -0
- package/dist/errors.d.ts +154 -0
- package/dist/errors.js +109 -0
- package/dist/feedback-config.d.ts +24 -0
- package/dist/feedback-config.js +126 -0
- package/dist/files.d.ts +39 -0
- package/dist/files.js +4 -0
- package/dist/groups.d.ts +95 -0
- package/dist/groups.js +280 -0
- package/dist/index.d.ts +109 -0
- package/dist/index.js +70 -0
- package/dist/internal/diagnostics.js +594 -0
- package/dist/legacy.d.ts +160 -0
- package/dist/legacy.js +270 -0
- package/dist/manifest-compatibility.d.ts +8 -0
- package/dist/manifest-compatibility.js +87 -0
- package/dist/manifest.d.ts +3329 -0
- package/dist/manifest.js +757 -0
- package/dist/me.d.ts +46 -0
- package/dist/me.js +109 -0
- package/dist/metric-filters.d.ts +48 -0
- package/dist/metric-filters.js +252 -0
- package/dist/metric-routes.d.ts +132 -0
- package/dist/metric-routes.js +498 -0
- package/dist/metric.d.ts +172 -0
- package/dist/metric.js +132 -0
- package/dist/migrate/apply.d.ts +94 -0
- package/dist/migrate/apply.js +526 -0
- package/dist/migrate/errors.d.ts +46 -0
- package/dist/migrate/errors.js +38 -0
- package/dist/migrate/index.d.ts +10 -0
- package/dist/migrate/index.js +7 -0
- package/dist/migrate/model.d.ts +2311 -0
- package/dist/migrate/model.js +148 -0
- package/dist/migrate/plan.d.ts +51 -0
- package/dist/migrate/plan.js +1856 -0
- package/dist/migrate/preview.d.ts +13 -0
- package/dist/migrate/preview.js +65 -0
- package/dist/migrate/storage.d.ts +226 -0
- package/dist/migrate/storage.js +774 -0
- package/dist/model-metrics.d.ts +22 -0
- package/dist/model-metrics.js +545 -0
- package/dist/names.d.ts +49 -0
- package/dist/names.js +110 -0
- package/dist/navigation-artifact.d.ts +13 -0
- package/dist/navigation-artifact.js +11 -0
- package/dist/navigation-catalog.d.ts +55 -0
- package/dist/navigation-catalog.js +297 -0
- package/dist/nitro-navigation.d.ts +3 -0
- package/dist/nitro-navigation.js +64 -0
- package/dist/nitro-workflows.d.ts +108 -0
- package/dist/nitro-workflows.js +286 -0
- package/dist/nitro.d.ts +40 -0
- package/dist/nitro.js +50 -0
- package/dist/ontology.d.ts +41 -0
- package/dist/ontology.js +46 -0
- package/dist/permissions.d.ts +198 -0
- package/dist/permissions.js +272 -0
- package/dist/property.d.ts +188 -0
- package/dist/property.js +301 -0
- package/dist/query.d.ts +245 -0
- package/dist/query.js +615 -0
- package/dist/relationship.d.ts +99 -0
- package/dist/relationship.js +33 -0
- package/dist/render.d.ts +81 -0
- package/dist/render.js +512 -0
- package/dist/replication/baseline.d.ts +51 -0
- package/dist/replication/baseline.js +215 -0
- package/dist/replication/bigquery.d.ts +37 -0
- package/dist/replication/bigquery.js +211 -0
- package/dist/replication/coverage.d.ts +14 -0
- package/dist/replication/coverage.js +53 -0
- package/dist/replication/evolution.d.ts +25 -0
- package/dist/replication/evolution.js +184 -0
- package/dist/replication/identities.d.ts +6 -0
- package/dist/replication/identities.js +47 -0
- package/dist/replication/legacy-baseline.d.ts +27 -0
- package/dist/replication/legacy-baseline.js +109 -0
- package/dist/replication/legacy-store.d.ts +19 -0
- package/dist/replication/legacy-store.js +63 -0
- package/dist/replication/lineage.d.ts +36 -0
- package/dist/replication/lineage.js +72 -0
- package/dist/replication/migrate.d.ts +21 -0
- package/dist/replication/migrate.js +84 -0
- package/dist/replication/model.d.ts +52 -0
- package/dist/replication/model.js +99 -0
- package/dist/replication/scans.d.ts +34 -0
- package/dist/replication/scans.js +158 -0
- package/dist/replication/schema.d.ts +11 -0
- package/dist/replication/schema.js +226 -0
- package/dist/replication/shadow-schema.d.ts +4 -0
- package/dist/replication/shadow-schema.js +61 -0
- package/dist/replication/shadow.d.ts +127 -0
- package/dist/replication/shadow.js +614 -0
- package/dist/replication/source-replacement.d.ts +33 -0
- package/dist/replication/source-replacement.js +76 -0
- package/dist/replication/store.d.ts +36 -0
- package/dist/replication/store.js +382 -0
- package/dist/replication/transaction.d.ts +23 -0
- package/dist/replication/transaction.js +88 -0
- package/dist/role.d.ts +61 -0
- package/dist/role.js +76 -0
- package/dist/runtime/action-deadlocks.d.ts +21 -0
- package/dist/runtime/action-deadlocks.js +130 -0
- package/dist/runtime/action-transactions.d.ts +35 -0
- package/dist/runtime/action-transactions.js +54 -0
- package/dist/runtime/agent-counters.d.ts +26 -0
- package/dist/runtime/agent-counters.js +98 -0
- package/dist/runtime/agent-tasks.d.ts +63 -0
- package/dist/runtime/agent-tasks.js +120 -0
- package/dist/runtime/agents.d.ts +15 -0
- package/dist/runtime/agents.js +457 -0
- package/dist/runtime/batch-diagnostics.d.ts +22 -0
- package/dist/runtime/batch-diagnostics.js +89 -0
- package/dist/runtime/batch.d.ts +52 -0
- package/dist/runtime/batch.js +534 -0
- package/dist/runtime/channel-session-revocation.d.ts +3 -0
- package/dist/runtime/channel-session-revocation.js +5 -0
- package/dist/runtime/channel-sessions.d.ts +4 -0
- package/dist/runtime/channel-sessions.js +61 -0
- package/dist/runtime/confirmations.d.ts +29 -0
- package/dist/runtime/confirmations.js +57 -0
- package/dist/runtime/cursor.d.ts +23 -0
- package/dist/runtime/cursor.js +100 -0
- package/dist/runtime/deployment.d.ts +7 -0
- package/dist/runtime/deployment.js +4 -0
- package/dist/runtime/errors.d.ts +118 -0
- package/dist/runtime/errors.js +109 -0
- package/dist/runtime/federation-tables.d.ts +2 -0
- package/dist/runtime/federation-tables.js +20 -0
- package/dist/runtime/federation.d.ts +42 -0
- package/dist/runtime/federation.js +222 -0
- package/dist/runtime/files.d.ts +39 -0
- package/dist/runtime/files.js +44 -0
- package/dist/runtime/generated-actions.d.ts +37 -0
- package/dist/runtime/generated-actions.js +307 -0
- package/dist/runtime/history.d.ts +91 -0
- package/dist/runtime/history.js +77 -0
- package/dist/runtime/ids.d.ts +6 -0
- package/dist/runtime/ids.js +39 -0
- package/dist/runtime/member-tables.d.ts +8 -0
- package/dist/runtime/member-tables.js +33 -0
- package/dist/runtime/member-writes.d.ts +78 -0
- package/dist/runtime/member-writes.js +202 -0
- package/dist/runtime/metric-query.d.ts +19 -0
- package/dist/runtime/metric-query.js +411 -0
- package/dist/runtime/model.d.ts +67 -0
- package/dist/runtime/model.js +150 -0
- package/dist/runtime/new-user-hook.d.ts +6 -0
- package/dist/runtime/new-user-hook.js +37 -0
- package/dist/runtime/oauth.d.ts +9 -0
- package/dist/runtime/oauth.js +299 -0
- package/dist/runtime/pipeline.d.ts +155 -0
- package/dist/runtime/pipeline.js +597 -0
- package/dist/runtime/postgres-tls.d.ts +10 -0
- package/dist/runtime/postgres-tls.js +14 -0
- package/dist/runtime/principals.d.ts +302 -0
- package/dist/runtime/principals.js +1307 -0
- package/dist/runtime/reads.d.ts +55 -0
- package/dist/runtime/reads.js +313 -0
- package/dist/runtime/records.d.ts +42 -0
- package/dist/runtime/records.js +245 -0
- package/dist/runtime/resolve-agent-identity.d.ts +6 -0
- package/dist/runtime/resolve-agent-identity.js +181 -0
- package/dist/runtime/service-binding.d.ts +18 -0
- package/dist/runtime/service-binding.js +266 -0
- package/dist/runtime/sign-in-links.d.ts +68 -0
- package/dist/runtime/sign-in-links.js +147 -0
- package/dist/runtime/sql-runtime.d.ts +131 -0
- package/dist/runtime/sql-runtime.js +362 -0
- package/dist/runtime/statement.d.ts +18 -0
- package/dist/runtime/statement.js +31 -0
- package/dist/runtime/user-identities.d.ts +66 -0
- package/dist/runtime/user-identities.js +106 -0
- package/dist/runtime/user-identity-sql.d.ts +86 -0
- package/dist/runtime/user-identity-sql.js +297 -0
- package/dist/runtime/user-roles.d.ts +16 -0
- package/dist/runtime/user-roles.js +32 -0
- package/dist/runtime/validation.d.ts +16 -0
- package/dist/runtime/validation.js +26 -0
- package/dist/runtime/where.d.ts +57 -0
- package/dist/runtime/where.js +404 -0
- package/dist/runtime/workflow-admission.d.ts +49 -0
- package/dist/runtime/workflow-admission.js +92 -0
- package/dist/runtime/workflow-argument-codec.d.ts +10 -0
- package/dist/runtime/workflow-argument-codec.js +120 -0
- package/dist/runtime/workflow-arguments.d.ts +17 -0
- package/dist/runtime/workflow-arguments.js +99 -0
- package/dist/runtime/workflow-starts.d.ts +203 -0
- package/dist/runtime/workflow-starts.js +493 -0
- package/dist/runtime/workflow-users.d.ts +9 -0
- package/dist/runtime/workflow-users.js +17 -0
- package/dist/runtime/workload.d.ts +25 -0
- package/dist/runtime/workload.js +24 -0
- package/dist/runtime/writes.d.ts +108 -0
- package/dist/runtime/writes.js +491 -0
- package/dist/runtime-source.d.ts +22 -0
- package/dist/runtime-source.js +28 -0
- package/dist/runtime.d.ts +310 -0
- package/dist/runtime.js +31 -0
- package/dist/server.d.ts +23 -0
- package/dist/server.js +107 -0
- package/dist/service/agent/attachments.d.ts +33 -0
- package/dist/service/agent/attachments.js +228 -0
- package/dist/service/agent/authentication.d.ts +22 -0
- package/dist/service/agent/authentication.js +58 -0
- package/dist/service/agent/confirmations.d.ts +5 -0
- package/dist/service/agent/confirmations.js +73 -0
- package/dist/service/agent/conversation-routes.d.ts +14 -0
- package/dist/service/agent/conversation-routes.js +45 -0
- package/dist/service/agent/conversations.d.ts +40 -0
- package/dist/service/agent/conversations.js +69 -0
- package/dist/service/agent/files.d.ts +12 -0
- package/dist/service/agent/files.js +69 -0
- package/dist/service/agent/protocol.d.ts +90 -0
- package/dist/service/agent/protocol.js +114 -0
- package/dist/service/agent/server.d.ts +28 -0
- package/dist/service/agent/server.js +374 -0
- package/dist/service/agent/task-caller.d.ts +21 -0
- package/dist/service/agent/task-caller.js +35 -0
- package/dist/service/agent/task-start.d.ts +53 -0
- package/dist/service/agent/task-start.js +161 -0
- package/dist/service/agent/tasks.d.ts +40 -0
- package/dist/service/agent/tasks.js +155 -0
- package/dist/service/api.d.ts +50 -0
- package/dist/service/api.js +288 -0
- package/dist/service/authentication.d.ts +227 -0
- package/dist/service/authentication.js +767 -0
- package/dist/service/caller-context.d.ts +24 -0
- package/dist/service/caller-context.js +6 -0
- package/dist/service/catalog.d.ts +42 -0
- package/dist/service/catalog.js +66 -0
- package/dist/service/channel/provider.d.ts +10 -0
- package/dist/service/channel/provider.js +1 -0
- package/dist/service/channel/providers/telegram.d.ts +18 -0
- package/dist/service/channel/providers/telegram.js +108 -0
- package/dist/service/channel/sessions.d.ts +5 -0
- package/dist/service/channel/sessions.js +53 -0
- package/dist/service/channel/sign-in.d.ts +7 -0
- package/dist/service/channel/sign-in.js +78 -0
- package/dist/service/chat/archive.d.ts +64 -0
- package/dist/service/chat/archive.js +62 -0
- package/dist/service/chat/attribution.d.ts +26 -0
- package/dist/service/chat/attribution.js +28 -0
- package/dist/service/chat/delivery.d.ts +18 -0
- package/dist/service/chat/delivery.js +251 -0
- package/dist/service/chat/diagnostics.d.ts +2 -0
- package/dist/service/chat/diagnostics.js +35 -0
- package/dist/service/chat/envelope.d.ts +96 -0
- package/dist/service/chat/envelope.js +39 -0
- package/dist/service/chat/idempotency.d.ts +19 -0
- package/dist/service/chat/idempotency.js +37 -0
- package/dist/service/chat/providers/telegram.d.ts +36 -0
- package/dist/service/chat/providers/telegram.js +133 -0
- package/dist/service/chat/reads.d.ts +25 -0
- package/dist/service/chat/reads.js +75 -0
- package/dist/service/chat/resolve.d.ts +34 -0
- package/dist/service/chat/resolve.js +150 -0
- package/dist/service/chat/routes.d.ts +23 -0
- package/dist/service/chat/routes.js +75 -0
- package/dist/service/chat/schemas.d.ts +43 -0
- package/dist/service/chat/schemas.js +31 -0
- package/dist/service/config.d.ts +218 -0
- package/dist/service/config.js +265 -0
- package/dist/service/connection-stream.d.ts +10 -0
- package/dist/service/connection-stream.js +191 -0
- package/dist/service/connections.d.ts +60 -0
- package/dist/service/connections.js +271 -0
- package/dist/service/decisions.d.ts +35 -0
- package/dist/service/decisions.js +63 -0
- package/dist/service/descriptions.d.ts +8 -0
- package/dist/service/descriptions.js +20 -0
- package/dist/service/discovery-schemas.d.ts +92 -0
- package/dist/service/discovery-schemas.js +59 -0
- package/dist/service/discovery-search.d.ts +30 -0
- package/dist/service/discovery-search.js +95 -0
- package/dist/service/discovery.d.ts +37 -0
- package/dist/service/discovery.js +436 -0
- package/dist/service/docs.d.ts +9 -0
- package/dist/service/docs.js +197 -0
- package/dist/service/errors.d.ts +145 -0
- package/dist/service/errors.js +704 -0
- package/dist/service/federation-provider.d.ts +36 -0
- package/dist/service/federation-provider.js +110 -0
- package/dist/service/federation-server.d.ts +20 -0
- package/dist/service/federation-server.js +249 -0
- package/dist/service/federation-status.d.ts +40 -0
- package/dist/service/federation-status.js +151 -0
- package/dist/service/handlers.d.ts +18 -0
- package/dist/service/handlers.js +287 -0
- package/dist/service/mcp/confirmation.d.ts +68 -0
- package/dist/service/mcp/confirmation.js +178 -0
- package/dist/service/mcp/find-input.d.ts +12 -0
- package/dist/service/mcp/find-input.js +122 -0
- package/dist/service/mcp/input-schema.d.ts +6 -0
- package/dist/service/mcp/input-schema.js +126 -0
- package/dist/service/mcp/listing.d.ts +26 -0
- package/dist/service/mcp/listing.js +92 -0
- package/dist/service/mcp/metric-tools.d.ts +85 -0
- package/dist/service/mcp/metric-tools.js +258 -0
- package/dist/service/mcp/request-log.d.ts +5 -0
- package/dist/service/mcp/request-log.js +53 -0
- package/dist/service/mcp/runtime-policy.d.ts +27 -0
- package/dist/service/mcp/runtime-policy.js +62 -0
- package/dist/service/mcp/server.d.ts +52 -0
- package/dist/service/mcp/server.js +861 -0
- package/dist/service/mcp/tools.d.ts +99 -0
- package/dist/service/mcp/tools.js +938 -0
- package/dist/service/member-file-routes.d.ts +22 -0
- package/dist/service/member-file-routes.js +52 -0
- package/dist/service/member-files.d.ts +245 -0
- package/dist/service/member-files.js +258 -0
- package/dist/service/metric-query-platform.d.ts +77 -0
- package/dist/service/metric-query-platform.js +198 -0
- package/dist/service/metric-query-scopes.d.ts +43 -0
- package/dist/service/metric-query-scopes.js +147 -0
- package/dist/service/metric-query.d.ts +48 -0
- package/dist/service/metric-query.js +200 -0
- package/dist/service/name-suggestions.d.ts +3 -0
- package/dist/service/name-suggestions.js +38 -0
- package/dist/service/navigation-schemas.d.ts +80 -0
- package/dist/service/navigation-schemas.js +49 -0
- package/dist/service/navigation.d.ts +20 -0
- package/dist/service/navigation.js +165 -0
- package/dist/service/oauth/protocol.d.ts +70 -0
- package/dist/service/oauth/protocol.js +157 -0
- package/dist/service/oauth/refresh-replay.d.ts +2 -0
- package/dist/service/oauth/refresh-replay.js +20 -0
- package/dist/service/oauth/server.d.ts +28 -0
- package/dist/service/oauth/server.js +769 -0
- package/dist/service/operations.d.ts +170 -0
- package/dist/service/operations.js +392 -0
- package/dist/service/plan.d.ts +57 -0
- package/dist/service/plan.js +96 -0
- package/dist/service/rate-limit.d.ts +22 -0
- package/dist/service/rate-limit.js +52 -0
- package/dist/service/record-reads.d.ts +21 -0
- package/dist/service/record-reads.js +155 -0
- package/dist/service/replication.d.ts +5 -0
- package/dist/service/replication.js +39 -0
- package/dist/service/runtime-contract.d.ts +508 -0
- package/dist/service/runtime-contract.js +18 -0
- package/dist/service/runtime-identity.d.ts +71 -0
- package/dist/service/runtime-identity.js +122 -0
- package/dist/service/schemas.d.ts +961 -0
- package/dist/service/schemas.js +639 -0
- package/dist/service/service.d.ts +21 -0
- package/dist/service/service.js +526 -0
- package/dist/service/warehouse-platform.d.ts +17 -0
- package/dist/service/warehouse-platform.js +122 -0
- package/dist/service/warehouse-query.d.ts +24 -0
- package/dist/service/warehouse-query.js +280 -0
- package/dist/service/webhooks.d.ts +106 -0
- package/dist/service/webhooks.js +233 -0
- package/dist/service/workflow-admission-provider.d.ts +31 -0
- package/dist/service/workflow-admission-provider.js +205 -0
- package/dist/service/workflow-argument-serialization.d.ts +2 -0
- package/dist/service/workflow-argument-serialization.js +14 -0
- package/dist/service/workflow-callback.d.ts +22 -0
- package/dist/service/workflow-callback.js +75 -0
- package/dist/service/workflow-deployment.d.ts +8 -0
- package/dist/service/workflow-deployment.js +131 -0
- package/dist/service/workflow-diagnostics.d.ts +23 -0
- package/dist/service/workflow-diagnostics.js +263 -0
- package/dist/service/workflow-errors.d.ts +26 -0
- package/dist/service/workflow-errors.js +181 -0
- package/dist/service/workflow-logging.d.ts +2 -0
- package/dist/service/workflow-logging.js +50 -0
- package/dist/service/workflow-queue-fence.d.ts +6 -0
- package/dist/service/workflow-queue-fence.js +66 -0
- package/dist/service/workflows.d.ts +200 -0
- package/dist/service/workflows.js +1549 -0
- package/dist/sign-in.d.ts +58 -0
- package/dist/sign-in.js +57 -0
- package/dist/source.d.ts +30 -0
- package/dist/source.js +107 -0
- package/dist/start-recovery-contract.d.ts +49 -0
- package/dist/start-recovery-contract.js +26 -0
- package/dist/start-recovery.d.ts +27 -0
- package/dist/start-recovery.js +81 -0
- package/dist/storage/api.d.ts +4 -0
- package/dist/storage/api.js +40 -0
- package/dist/storage/attachments.d.ts +146 -0
- package/dist/storage/attachments.js +733 -0
- package/dist/storage/authorization.d.ts +38 -0
- package/dist/storage/authorization.js +83 -0
- package/dist/storage/file-access.d.ts +17 -0
- package/dist/storage/file-access.js +8 -0
- package/dist/storage/limits.d.ts +12 -0
- package/dist/storage/limits.js +17 -0
- package/dist/storage/maintenance-auth.d.ts +20 -0
- package/dist/storage/maintenance-auth.js +24 -0
- package/dist/storage/maintenance.d.ts +11 -0
- package/dist/storage/maintenance.js +67 -0
- package/dist/storage/neon.d.ts +4 -0
- package/dist/storage/neon.js +318 -0
- package/dist/storage/operator.d.ts +11 -0
- package/dist/storage/operator.js +30 -0
- package/dist/storage/provider.d.ts +93 -0
- package/dist/storage/provider.js +36 -0
- package/dist/storage/sessions.d.ts +184 -0
- package/dist/storage/sessions.js +688 -0
- package/dist/storage/tables.d.ts +54 -0
- package/dist/storage/tables.js +205 -0
- package/dist/storage/transfers.d.ts +16 -0
- package/dist/storage/transfers.js +153 -0
- package/dist/storage/writes.d.ts +7 -0
- package/dist/storage/writes.js +88 -0
- package/dist/telegram.d.ts +35 -0
- package/dist/telegram.js +173 -0
- package/dist/user-identity.d.ts +28 -0
- package/dist/user-identity.js +106 -0
- package/dist/user.d.ts +75 -0
- package/dist/user.js +111 -0
- package/dist/warehouse-backed.d.ts +6 -0
- package/dist/warehouse-backed.js +495 -0
- package/dist/webhook-token.d.ts +11 -0
- package/dist/webhook-token.js +18 -0
- package/dist/webhooks.d.ts +40 -0
- package/dist/webhooks.js +55 -0
- package/dist/wire.d.ts +10 -0
- package/dist/wire.js +35 -0
- package/dist/workflow-runtime.d.ts +116 -0
- package/dist/workflow-runtime.js +109 -0
- package/dist/workflow-topics.d.ts +28 -0
- package/dist/workflow-topics.js +28 -0
- package/dist/workflow.d.ts +284 -0
- package/dist/workflow.js +259 -0
- package/dist/workflows.d.ts +24 -0
- package/dist/workflows.js +18 -0
- package/package.json +127 -4
package/dist/analyze.js
ADDED
|
@@ -0,0 +1,2438 @@
|
|
|
1
|
+
import { validateBrowserSignIn } from "./sign-in.js";
|
|
2
|
+
import { checkWarehouseSource } from "./warehouse-backed.js";
|
|
3
|
+
import { Result, Schema } from "effect";
|
|
4
|
+
import { BLOB_UPLOAD_ANNOTATION } from "./blob.js";
|
|
5
|
+
import { isBlobAccept, MAX_BLOB_BYTES } from "./blob.js";
|
|
6
|
+
import { isAction } from "./action.js";
|
|
7
|
+
import { CONNECTION_KEY, isConnection } from "./connection.js";
|
|
8
|
+
import { isEntity } from "./entity.js";
|
|
9
|
+
import { QUERY_METRICS_ROUTE, LEGACY_MEMBER_NAME, ENTITY_ID, GENERATED_ACTIONS, GENERATED_UPSERT_PREFIX, HISTORY_SUFFIX, MCP_METRIC_TOOLS, MCP_READ_TOOLS, MCP_TOOL_NAME_MAX_LENGTH, MEMBER_ROLE, FEEDBACK_REPORTER_ROLE, mcpToolName, RESERVED_ENTITY_NAMES, RESERVED_MEMBER_NAMES, RESERVED_RELATIONSHIP_NAMES, SNAKE_CASE_NAME, SYSTEM_PROPERTIES, isSystemProperty, toCamelCase, } from "./names.js";
|
|
10
|
+
import { REFERENCE_ANNOTATION, propertyDefinition, propertyOptionNames, unknownPropertyOptions, referenceTarget, } from "./property.js";
|
|
11
|
+
import { generatedActionPermissions, grants, isAllowed, normalizePermission, parsePermission, parseWorkflowPermission, permissionOf, READ_VERB, rolesGranting, USER_MEMBER_VERBS, WILDCARD, wildcardEntityCovers, } from "./permissions.js";
|
|
12
|
+
import { ON_DELETE, isRelationship } from "./relationship.js";
|
|
13
|
+
import { isRole, member, role } from "./role.js";
|
|
14
|
+
import { CHANNEL_PROVIDERS, DEFAULT_SIGN_IN_POLICY, isSignInPolicy, } from "./sign-in.js";
|
|
15
|
+
import { emailOptions } from "./emails.js";
|
|
16
|
+
import { AgentChat } from "./agent-chat.js";
|
|
17
|
+
import { APP_FEEDBACK_ENTITIES } from "./app-feedback.js";
|
|
18
|
+
import { feedbackOptions, feedbackRoles, checkFeedbackResponders, } from "./feedback-config.js";
|
|
19
|
+
import { User } from "./user.js";
|
|
20
|
+
import { UserIdentity } from "./user-identity.js";
|
|
21
|
+
import { authoringToWire } from "./authoring.js";
|
|
22
|
+
import { nameToWire } from "./authoring-names.js";
|
|
23
|
+
import { buildManifest, canonicalJson, } from "./manifest.js";
|
|
24
|
+
import { MAX_ME_HOPS, mePathOf } from "./me.js";
|
|
25
|
+
import { entityQuerySchemas, findEntityManifest, queryFields, queryLinks, } from "./query.js";
|
|
26
|
+
import { EARLIER_QUERY_METRICS_ROUTE, earlierNamesOf } from "./legacy.js";
|
|
27
|
+
import { resolveModelMetrics } from "./model-metrics.js";
|
|
28
|
+
import { cronNeverFires, cronProblem, isAsyncFunction, isWorkflow, isWorkflowTrigger, MAX_ACTION_TRIGGERS, MAX_WORKFLOWS, } from "./workflow.js";
|
|
29
|
+
import { MAX_WEBHOOK_HEADERS, MAX_WEBHOOKS, WEBHOOK_SIGNATURE_SCHEMES, webhookDelivery, webhookHeaderProblem, WORKSPACE_SECRET_NAME, } from "./webhooks.js";
|
|
30
|
+
import { actionInputToWire } from "./authoring-input.js";
|
|
31
|
+
import { validate } from "./runtime/validation.js";
|
|
32
|
+
const GENERATED_UPSERT = new RegExp(`^${GENERATED_UPSERT_PREFIX}`);
|
|
33
|
+
function errorMessage(error) {
|
|
34
|
+
return error instanceof Error ? error.message : String(error);
|
|
35
|
+
}
|
|
36
|
+
function isBlank(value) {
|
|
37
|
+
return typeof value !== "string" || value.trim().length === 0;
|
|
38
|
+
}
|
|
39
|
+
function isPlainObject(value) {
|
|
40
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Resolves an ontology definition and checks it. It never throws: every problem, including a thunk
|
|
44
|
+
* that throws, becomes a diagnostic.
|
|
45
|
+
*/
|
|
46
|
+
export function analyze(config, options = {}) {
|
|
47
|
+
const diagnostics = [];
|
|
48
|
+
const report = (code, path, message, severity = "error") => {
|
|
49
|
+
diagnostics.push({ severity, code, path, message });
|
|
50
|
+
};
|
|
51
|
+
const original = config;
|
|
52
|
+
const feedbackSetting = feedbackOptions(config.feedback, report);
|
|
53
|
+
const normalized = authoringToWire(config, [
|
|
54
|
+
options.user ?? User,
|
|
55
|
+
AgentChat,
|
|
56
|
+
UserIdentity,
|
|
57
|
+
...(feedbackSetting !== false ? APP_FEEDBACK_ENTITIES : []),
|
|
58
|
+
], report);
|
|
59
|
+
config = normalized.config;
|
|
60
|
+
reportEarlierNames(original, report);
|
|
61
|
+
let entities = collectEntities(config, normalized.builtIns, report);
|
|
62
|
+
const declaredRoles = collectRoles(config, report);
|
|
63
|
+
if (config.hooks !== undefined &&
|
|
64
|
+
(!isPlainObject(config.hooks) ||
|
|
65
|
+
Object.keys(config.hooks).some((key) => key !== "role") ||
|
|
66
|
+
typeof config.hooks.role !== "string" ||
|
|
67
|
+
nameToWire(config.hooks.role) === FEEDBACK_REPORTER_ROLE ||
|
|
68
|
+
!declaredRoles.some((candidate) => candidate.name === nameToWire(config.hooks.role)))) {
|
|
69
|
+
report("invalid-permission", "hooks.role", "hooks.role must name a declared ontology role.");
|
|
70
|
+
}
|
|
71
|
+
const signIn = signInOptions(config, report);
|
|
72
|
+
const discovery = config.discovery;
|
|
73
|
+
if (discovery !== undefined &&
|
|
74
|
+
(!isPlainObject(discovery) ||
|
|
75
|
+
Object.keys(discovery).some((key) => key !== "model") ||
|
|
76
|
+
typeof discovery.model !== "string" ||
|
|
77
|
+
!/^[a-z0-9][a-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._:-]*$/.test(discovery.model))) {
|
|
78
|
+
report("invalid-discovery", "discovery", 'discovery takes { model: "<provider>/<model>" }, a decision model on AI Gateway such as ' +
|
|
79
|
+
'"typesafe-ai/jev-1.14.0".');
|
|
80
|
+
}
|
|
81
|
+
const emails = emailOptions(config.emails, report, (name, path) => camelCaseName(name, path, "agreementReview", "Email", report));
|
|
82
|
+
const connections = collectConnections(config, report);
|
|
83
|
+
const entitiesByName = new Map();
|
|
84
|
+
for (const candidate of entities) {
|
|
85
|
+
if (!entitiesByName.has(candidate.name)) {
|
|
86
|
+
entitiesByName.set(candidate.name, candidate);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
let scope = {
|
|
90
|
+
report,
|
|
91
|
+
builtIns: new Set(normalized.builtIns.map((candidate) => candidate.definition)),
|
|
92
|
+
entitiesByName,
|
|
93
|
+
definitions: new Set(entities.map((candidate) => candidate.definition)),
|
|
94
|
+
entityProperties: new Set([...entities, ...(original.entities ?? []).filter(isEntity)].flatMap((candidate) => Object.values(candidate.definition.properties ?? {}))),
|
|
95
|
+
connections: new Set(connections.map((candidate) => candidate.key)),
|
|
96
|
+
};
|
|
97
|
+
// Resolve authored properties first. The feedback blob must never enable itself.
|
|
98
|
+
const properties = new Map(entities
|
|
99
|
+
.filter((candidate) => !APP_FEEDBACK_ENTITIES.some((feedback) => feedback.name === candidate.name))
|
|
100
|
+
.map((candidate) => [candidate, resolveProperties(candidate, scope)]));
|
|
101
|
+
const hasOtherBlob = [...properties.values()].some((values) => values.some((property) => property.definition.type === "blob"));
|
|
102
|
+
const files = feedbackSetting !== false &&
|
|
103
|
+
(feedbackSetting === true ||
|
|
104
|
+
feedbackSetting.files === true ||
|
|
105
|
+
(feedbackSetting.files === undefined && hasOtherBlob));
|
|
106
|
+
if (!files && feedbackSetting !== false) {
|
|
107
|
+
const file = normalized.builtIns.find((candidate) => candidate.name === "app_feedback_file");
|
|
108
|
+
entities = entities.filter((candidate) => candidate !== file);
|
|
109
|
+
entitiesByName.delete("app_feedback_file");
|
|
110
|
+
scope = {
|
|
111
|
+
...scope,
|
|
112
|
+
builtIns: new Set(normalized.builtIns
|
|
113
|
+
.filter((candidate) => candidate !== file)
|
|
114
|
+
.map((candidate) => candidate.definition)),
|
|
115
|
+
definitions: new Set(entities.map((candidate) => candidate.definition)),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const { feedback, roles } = feedbackRoles(feedbackSetting, files, declaredRoles, report);
|
|
119
|
+
// Resolve authored entities before the optional feedback entities, preserving their ordering.
|
|
120
|
+
const isFeedback = (candidate) => APP_FEEDBACK_ENTITIES.some((feedback) => feedback.name === candidate.name);
|
|
121
|
+
let resolved = [
|
|
122
|
+
...entities
|
|
123
|
+
.filter((candidate) => !isFeedback(candidate))
|
|
124
|
+
.map((candidate) => resolveEntity(candidate, scope, properties.get(candidate))),
|
|
125
|
+
...entities.filter(isFeedback).map((candidate) => resolveEntity(candidate, scope)),
|
|
126
|
+
];
|
|
127
|
+
let chat;
|
|
128
|
+
if (config.chat !== undefined) {
|
|
129
|
+
if (!isPlainObject(config.chat) ||
|
|
130
|
+
Object.keys(config.chat).some((key) => key !== "telegram" && key !== "attribution") ||
|
|
131
|
+
(config.chat.telegram !== undefined &&
|
|
132
|
+
(!isPlainObject(config.chat.telegram) ||
|
|
133
|
+
Object.keys(config.chat.telegram).some((key) => key !== "connection") ||
|
|
134
|
+
!isConnection(config.chat.telegram.connection)))) {
|
|
135
|
+
report("invalid-connection", "chat", "chat.telegram.connection must reference a declared connection handle.");
|
|
136
|
+
}
|
|
137
|
+
else if (config.chat.telegram !== undefined) {
|
|
138
|
+
const handle = config.chat.telegram.connection;
|
|
139
|
+
if (!(config.connections ?? []).includes(handle)) {
|
|
140
|
+
report("invalid-connection", "chat.telegram.connection", "The chat provider connection must be listed in connections.");
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
chat = {
|
|
144
|
+
telegram: { connection: handle.key },
|
|
145
|
+
...(config.chat.attribution === undefined
|
|
146
|
+
? {}
|
|
147
|
+
: { attribution: config.chat.attribution }),
|
|
148
|
+
};
|
|
149
|
+
resolved = resolved.map((entity) => entity.name !== "agent_chat"
|
|
150
|
+
? entity
|
|
151
|
+
: {
|
|
152
|
+
...entity,
|
|
153
|
+
actions: entity.actions.map((candidate) => candidate.name !== "post_message"
|
|
154
|
+
? candidate
|
|
155
|
+
: {
|
|
156
|
+
...candidate,
|
|
157
|
+
connections: [handle.key],
|
|
158
|
+
action: { ...candidate.action, connections: [handle] },
|
|
159
|
+
}),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const attribution = config.chat?.attribution;
|
|
165
|
+
if (attribution !== undefined &&
|
|
166
|
+
(!isPlainObject(attribution) ||
|
|
167
|
+
Object.keys(attribution).some((key) => key !== "template" && key !== "separator") ||
|
|
168
|
+
(attribution.template !== undefined &&
|
|
169
|
+
(typeof attribution.template !== "string" ||
|
|
170
|
+
attribution.template.length > 512 ||
|
|
171
|
+
!attribution.template.includes("{name}"))) ||
|
|
172
|
+
(attribution.separator !== undefined &&
|
|
173
|
+
(typeof attribution.separator !== "string" || attribution.separator.length > 100)))) {
|
|
174
|
+
report("invalid-connection", "chat.attribution", "Attribution requires a template containing {name} (at most 512 characters) " +
|
|
175
|
+
"and a separator of at most 100 characters.");
|
|
176
|
+
}
|
|
177
|
+
checkEntityIdentities(resolved, report);
|
|
178
|
+
checkMemberNames(resolved, report);
|
|
179
|
+
checkToolNames(resolved, report);
|
|
180
|
+
checkWarehouseSource(resolved, report);
|
|
181
|
+
const metrics = resolveModelMetrics({ metrics: config.metrics, entities: resolved, report });
|
|
182
|
+
const workflows = resolveWorkflows({
|
|
183
|
+
workflows: original.workflows,
|
|
184
|
+
entities: resolved,
|
|
185
|
+
roles,
|
|
186
|
+
scope,
|
|
187
|
+
checkGrants: options.checkPermissions !== false,
|
|
188
|
+
});
|
|
189
|
+
checkConnectionsUsed(resolved, connections, config.connections ?? [], workflows, report);
|
|
190
|
+
if (options.checkPermissions !== false) {
|
|
191
|
+
// Validate authored grants before augmentation can replace a scoped grant with an unscoped
|
|
192
|
+
// builder grant. Invalid authored scopes must remain errors; generated redundancy needs no warning.
|
|
193
|
+
checkPermissions(resolved, roles, workflows, report, [
|
|
194
|
+
...declaredRoles,
|
|
195
|
+
...roles.filter((candidate) => candidate.name === FEEDBACK_REPORTER_ROLE),
|
|
196
|
+
]);
|
|
197
|
+
checkOpenWarehouseReads({ entities: resolved, roles, signIn, report });
|
|
198
|
+
checkOpenChannels({ config, entities: resolved, roles, signIn, report });
|
|
199
|
+
checkNewUserAction({ entities: resolved, roles, signIn, report });
|
|
200
|
+
if (feedback.enabled) {
|
|
201
|
+
checkFeedbackResponders(roles, report);
|
|
202
|
+
}
|
|
203
|
+
if (Object.values(signIn.channels ?? {}).includes("open") &&
|
|
204
|
+
options.agentConfig !== undefined) {
|
|
205
|
+
const contact = options.agentConfig.firstContact;
|
|
206
|
+
const hasNotice = isPlainObject(contact) &&
|
|
207
|
+
isPlainObject(contact.notice) &&
|
|
208
|
+
Object.values(contact.notice).some((notice) => typeof notice === "string" && notice.trim().length > 0);
|
|
209
|
+
const limits = options.agentConfig.limits;
|
|
210
|
+
const hasLimits = isPlainObject(limits) && Object.keys(limits).length > 0;
|
|
211
|
+
for (const [key, present] of [
|
|
212
|
+
["firstContact.notice", hasNotice],
|
|
213
|
+
["limits", hasLimits],
|
|
214
|
+
]) {
|
|
215
|
+
if (!present) {
|
|
216
|
+
report("invalid-sign-in", `agent.${key}`, `Open channels have no agent ${key} configuration.`, "warning");
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
feedback,
|
|
223
|
+
entities: resolved,
|
|
224
|
+
roles,
|
|
225
|
+
signIn,
|
|
226
|
+
emails,
|
|
227
|
+
connections,
|
|
228
|
+
metrics,
|
|
229
|
+
workflows,
|
|
230
|
+
diagnostics,
|
|
231
|
+
...(chat === undefined ? {} : { chat }),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Warns where workspace code declares a field by the name an earlier SDK gave it, naming the current
|
|
236
|
+
* one, so that authors move to it while the earlier name is still accepted.
|
|
237
|
+
*/
|
|
238
|
+
function reportEarlierNames(config, report) {
|
|
239
|
+
const declared = config.entities;
|
|
240
|
+
// Where each definition's fields are: an entity listed before and after `.actions(…)` shares one.
|
|
241
|
+
const paths = new Map([[config, ""]]);
|
|
242
|
+
for (const candidate of Array.isArray(declared) ? declared : []) {
|
|
243
|
+
if (isEntity(candidate) && !paths.has(candidate.definition)) {
|
|
244
|
+
paths.set(candidate.definition, `${candidate.name}.`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
for (const [definition, path] of paths) {
|
|
248
|
+
for (const { earlier, current } of earlierNamesOf(definition)) {
|
|
249
|
+
report("earlier-name", `${path}${current}`, `${earlier} is the deprecated earlier name of ${current}; rename it to ${current}.`, "warning");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/** The wire form of a camelCase declaration name, reporting a name that cannot round-trip. */
|
|
254
|
+
function camelCaseName(name, path, example, kind, report) {
|
|
255
|
+
if (typeof name !== "string" || !/^[a-z][a-zA-Z0-9]*$/.test(name)) {
|
|
256
|
+
report("invalid-name", path, `${kind} names are lower camelCase identifiers, for example ${example}.`);
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
const wire = nameToWire(name);
|
|
260
|
+
if (toCamelCase(wire) !== name || !SNAKE_CASE_NAME.test(wire)) {
|
|
261
|
+
report("invalid-name", path, `Use lower camelCase ${toCamelCase(wire)} so the TypeScript and wire names round-trip.`);
|
|
262
|
+
return undefined;
|
|
263
|
+
}
|
|
264
|
+
return wire;
|
|
265
|
+
}
|
|
266
|
+
/** An input as JSON Schema (draft 2020-12), as the manifest describes action inputs. */
|
|
267
|
+
function inputJsonSchema(input) {
|
|
268
|
+
const document = Schema.toJsonSchemaDocument(input, {
|
|
269
|
+
includeAnnotationKey: (key) => key === REFERENCE_ANNOTATION || key === BLOB_UPLOAD_ANNOTATION,
|
|
270
|
+
onExcessProperty: "error",
|
|
271
|
+
});
|
|
272
|
+
const schema = document.schema;
|
|
273
|
+
return Object.keys(document.definitions).length === 0
|
|
274
|
+
? schema
|
|
275
|
+
: { ...schema, $defs: document.definitions };
|
|
276
|
+
}
|
|
277
|
+
/** The input of a workflow that declares none: exactly `{}`. */
|
|
278
|
+
export const NO_INPUT = Schema.Record(Schema.String, Schema.Never);
|
|
279
|
+
/** A workflow's input schema, `{}` when it declares none, or a message when it is not a struct. */
|
|
280
|
+
function workflowInputSchema(input) {
|
|
281
|
+
if (input === undefined) {
|
|
282
|
+
return NO_INPUT;
|
|
283
|
+
}
|
|
284
|
+
if (!Schema.isSchema(input) || Schema.toEncoded(input).ast._tag !== "Objects") {
|
|
285
|
+
return "must be a Schema.Struct of the workflow's input.";
|
|
286
|
+
}
|
|
287
|
+
return input;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Whether `member`'s and the role's permissions together let the workflow's runs do anything:
|
|
291
|
+
* create, change or delete records of some entity, or run some action. Reads alone do not count.
|
|
292
|
+
*/
|
|
293
|
+
function grantsSomeAction(entities, permissions) {
|
|
294
|
+
const holder = { permissions };
|
|
295
|
+
return entities.some((entity) => entity.actions.some((candidate) => isAllowed(candidate.permission, holder)) ||
|
|
296
|
+
Object.entries(entity.permissions).some(([verb, permission]) => verb !== READ_VERB && permission !== undefined && isAllowed(permission, holder)));
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Checks the workflows and resolves them to their manifest form: wire names, the input as JSON
|
|
300
|
+
* Schema, the connections' keys, and each trigger's input decoded with the workflow's input and
|
|
301
|
+
* stored in its wire form. A workflow runs as a declared role other than `member`.
|
|
302
|
+
*/
|
|
303
|
+
function resolveWorkflows(input) {
|
|
304
|
+
const { entities, roles, scope } = input;
|
|
305
|
+
const { report } = scope;
|
|
306
|
+
if (input.workflows === undefined) {
|
|
307
|
+
return [];
|
|
308
|
+
}
|
|
309
|
+
if (!Array.isArray(input.workflows)) {
|
|
310
|
+
report("invalid-workflow", "workflows", "workflows is a list of workflow(name, { … }).");
|
|
311
|
+
return [];
|
|
312
|
+
}
|
|
313
|
+
if (input.workflows.length > MAX_WORKFLOWS) {
|
|
314
|
+
report("invalid-workflow", "workflows", `An ontology may declare at most ${MAX_WORKFLOWS} workflows; it declares ` +
|
|
315
|
+
`${input.workflows.length}.`);
|
|
316
|
+
}
|
|
317
|
+
const memberPermissions = roles.find((candidate) => candidate.name === MEMBER_ROLE)?.permissions;
|
|
318
|
+
const names = new Set();
|
|
319
|
+
const resolved = [];
|
|
320
|
+
input.workflows.forEach((candidate, index) => {
|
|
321
|
+
if (!isWorkflow(candidate)) {
|
|
322
|
+
report("invalid-workflow", `workflows[${index}]`, "is not a workflow; declare workflows with workflow(name, { description, role, input, " +
|
|
323
|
+
"run, triggers }).");
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const path = `workflows.${String(candidate.name)}`;
|
|
327
|
+
const name = camelCaseName(candidate.name, path, "remindStaleLeads", "workflow", report);
|
|
328
|
+
let valid = name !== undefined;
|
|
329
|
+
if (name !== undefined) {
|
|
330
|
+
if (names.has(name)) {
|
|
331
|
+
report("duplicate-name", path, `workflow "${name}" is declared twice.`);
|
|
332
|
+
valid = false;
|
|
333
|
+
}
|
|
334
|
+
names.add(name);
|
|
335
|
+
}
|
|
336
|
+
if (isBlank(candidate.description)) {
|
|
337
|
+
report("missing-description", path, "every workflow needs a description.");
|
|
338
|
+
valid = false;
|
|
339
|
+
}
|
|
340
|
+
if (typeof candidate.run !== "function") {
|
|
341
|
+
report("workflow_run_missing", `${path}.run`, 'run is the workflow\'s "use workflow" function, such as run: remindStaleLeads.');
|
|
342
|
+
valid = false;
|
|
343
|
+
}
|
|
344
|
+
const role = typeof candidate.role === "string" ? nameToWire(candidate.role) : undefined;
|
|
345
|
+
const declared = roles.find((existing) => existing.name === role);
|
|
346
|
+
if (role === FEEDBACK_REPORTER_ROLE) {
|
|
347
|
+
report("workflow_role_unknown", `${path}.role`, "feedback_reporter is implicit human authority and cannot be a workflow role.");
|
|
348
|
+
valid = false;
|
|
349
|
+
}
|
|
350
|
+
else if (role === MEMBER_ROLE) {
|
|
351
|
+
report("workflow_role_is_member", `${path}.role`, "a workflow runs as a declared role of its own; its runs hold member's permissions " +
|
|
352
|
+
"anyway, so name the role that adds what the workflow does.");
|
|
353
|
+
valid = false;
|
|
354
|
+
}
|
|
355
|
+
else if (declared === undefined) {
|
|
356
|
+
report("workflow_role_unknown", `${path}.role`, `${JSON.stringify(candidate.role) ?? "undefined"} is not a declared role; a workflow ` +
|
|
357
|
+
"runs as one of the ontology's roles, other than member.");
|
|
358
|
+
valid = false;
|
|
359
|
+
}
|
|
360
|
+
else if (Array.isArray(declared.permissions) && declared.permissions.includes("*:*")) {
|
|
361
|
+
report("workflow_role_wildcard", `${path}.role`, `${declared.name} grants *:*, everything now and later; a workflow runs as a role that ` +
|
|
362
|
+
"names what it does.");
|
|
363
|
+
valid = false;
|
|
364
|
+
}
|
|
365
|
+
else if (input.checkGrants &&
|
|
366
|
+
!grantsSomeAction(entities, [
|
|
367
|
+
...(Array.isArray(memberPermissions) ? memberPermissions : []),
|
|
368
|
+
...(Array.isArray(declared.permissions) ? declared.permissions : []),
|
|
369
|
+
])) {
|
|
370
|
+
report("workflow_role_grants_nothing", `${path}.role`, `${declared.name}, with member's permissions, grants no action, so the workflow can only ` +
|
|
371
|
+
"read; add the actions it runs to the role's permissions.", "warning");
|
|
372
|
+
}
|
|
373
|
+
if (input.checkGrants &&
|
|
374
|
+
declared !== undefined &&
|
|
375
|
+
role !== MEMBER_ROLE &&
|
|
376
|
+
(declared.scopes ?? []).length > 0) {
|
|
377
|
+
const scoped = [...new Set((declared.scopes ?? []).map((scope) => scope.permission))];
|
|
378
|
+
report("workflow_role_scoped", `${path}.role`, `${declared.name} scopes ${scoped.join(", ")}; bare me is the workflow service user's ID. ` +
|
|
379
|
+
"Paths from me require relationships of that service user; verify the scope matches the intended records.", "warning");
|
|
380
|
+
}
|
|
381
|
+
const schema = workflowInputSchema(candidate.input);
|
|
382
|
+
if (typeof schema !== "string" && "fields" in schema) {
|
|
383
|
+
for (const [field, value] of Object.entries(schema.fields)) {
|
|
384
|
+
checkPropertyOptions(`${path}.input.${field}`, value, report);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
let wire;
|
|
388
|
+
let jsonSchema;
|
|
389
|
+
if (typeof schema === "string") {
|
|
390
|
+
report("workflow_input_invalid", `${path}.input`, schema);
|
|
391
|
+
valid = false;
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
try {
|
|
395
|
+
wire = actionInputToWire(schema);
|
|
396
|
+
// A workflow without input takes exactly `{}`.
|
|
397
|
+
jsonSchema =
|
|
398
|
+
candidate.input === undefined
|
|
399
|
+
? { type: "object", properties: {}, additionalProperties: false }
|
|
400
|
+
: inputJsonSchema(wire);
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
report("workflow_input_invalid", `${path}.input`, `has no JSON Schema form: ${errorMessage(error)}`);
|
|
404
|
+
valid = false;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const connections = workflowConnections(path, candidate.connections, scope);
|
|
408
|
+
const triggers = [];
|
|
409
|
+
const triggerNames = new Set();
|
|
410
|
+
if (!Array.isArray(candidate.triggers)) {
|
|
411
|
+
report("invalid-workflow", `${path}.triggers`, "triggers is a list of cron(name, …), after(name, …) and webhook(name, …).");
|
|
412
|
+
valid = false;
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
candidate.triggers.forEach((trigger, position) => {
|
|
416
|
+
if (!isWorkflowTrigger(trigger) ||
|
|
417
|
+
(trigger.kind !== "cron" && trigger.kind !== "action" && trigger.kind !== "webhook")) {
|
|
418
|
+
report("invalid-workflow", `${path}.triggers[${position}]`, "is not a trigger; declare triggers with cron(name, expression, { description }), " +
|
|
419
|
+
"after(name, target, { description }) or webhook(name, { description }).");
|
|
420
|
+
valid = false;
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const at = `${path}.triggers.${String(trigger.name)}`;
|
|
424
|
+
const example = trigger.kind === "cron"
|
|
425
|
+
? "weekdayMorning"
|
|
426
|
+
: trigger.kind === "action"
|
|
427
|
+
? "qualified"
|
|
428
|
+
: "stripe";
|
|
429
|
+
const triggerName = camelCaseName(trigger.name, at, example, "trigger", report);
|
|
430
|
+
if (triggerName === undefined) {
|
|
431
|
+
valid = false;
|
|
432
|
+
}
|
|
433
|
+
else if (triggerNames.has(triggerName)) {
|
|
434
|
+
report("duplicate-name", at, `the trigger ${String(trigger.name)} of ${String(candidate.name)} is declared twice.`);
|
|
435
|
+
valid = false;
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
triggerNames.add(triggerName);
|
|
439
|
+
}
|
|
440
|
+
if (isBlank(trigger.description)) {
|
|
441
|
+
report("missing-description", at, "every trigger needs a description.");
|
|
442
|
+
valid = false;
|
|
443
|
+
}
|
|
444
|
+
const resolvedTrigger = trigger.kind === "cron"
|
|
445
|
+
? resolveCronTrigger(trigger, at, schema, wire, report)
|
|
446
|
+
: trigger.kind === "action"
|
|
447
|
+
? resolveActionTrigger(trigger, at, entities, schema, wire, report)
|
|
448
|
+
: resolveWebhookTrigger(trigger, at, report);
|
|
449
|
+
if (resolvedTrigger === undefined) {
|
|
450
|
+
valid = false;
|
|
451
|
+
}
|
|
452
|
+
else if (triggerName !== undefined && !isBlank(trigger.description)) {
|
|
453
|
+
triggers.push({ ...resolvedTrigger, name: triggerName });
|
|
454
|
+
}
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
const kinds = new Set((Array.isArray(candidate.triggers) ? candidate.triggers : [])
|
|
458
|
+
.filter(isWorkflowTrigger)
|
|
459
|
+
.map((trigger) => trigger.kind));
|
|
460
|
+
if (kinds.has("webhook")) {
|
|
461
|
+
if (kinds.size > 1) {
|
|
462
|
+
report("workflow_webhook_mixed_triggers", `${path}.triggers`, "a workflow that a webhook starts takes a delivery as its input, so it has only webhook " +
|
|
463
|
+
"triggers; start other work from a second workflow.");
|
|
464
|
+
valid = false;
|
|
465
|
+
}
|
|
466
|
+
if (jsonSchema !== undefined &&
|
|
467
|
+
canonicalJson(jsonSchema) !== canonicalJson(webhookDeliveryJsonSchema())) {
|
|
468
|
+
report("workflow_webhook_input_invalid", `${path}.input`, "a workflow that a webhook starts takes one delivery: set input: webhookDelivery, from " +
|
|
469
|
+
"@segmentstream/ontology/workflows.");
|
|
470
|
+
valid = false;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (valid &&
|
|
474
|
+
name !== undefined &&
|
|
475
|
+
declared !== undefined &&
|
|
476
|
+
jsonSchema !== undefined &&
|
|
477
|
+
connections !== undefined) {
|
|
478
|
+
resolved.push({
|
|
479
|
+
name,
|
|
480
|
+
description: candidate.description,
|
|
481
|
+
role: declared.name,
|
|
482
|
+
input: jsonSchema,
|
|
483
|
+
connections,
|
|
484
|
+
triggers,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
const actionTriggers = input.workflows
|
|
489
|
+
.filter(isWorkflow)
|
|
490
|
+
.flatMap((candidate) => (Array.isArray(candidate.triggers) ? candidate.triggers : []))
|
|
491
|
+
.filter((trigger) => isWorkflowTrigger(trigger) && trigger.kind === "action").length;
|
|
492
|
+
if (actionTriggers > MAX_ACTION_TRIGGERS) {
|
|
493
|
+
report("workflow_trigger_limit_exceeded", "workflows", `An ontology may declare at most ${MAX_ACTION_TRIGGERS} action triggers; it declares ` +
|
|
494
|
+
`${actionTriggers}.`);
|
|
495
|
+
}
|
|
496
|
+
// A webhook's wire name is its URL, so it is unique across the ontology, not only its workflow.
|
|
497
|
+
const webhooks = new Map();
|
|
498
|
+
for (const candidate of input.workflows.filter(isWorkflow)) {
|
|
499
|
+
for (const trigger of Array.isArray(candidate.triggers) ? candidate.triggers : []) {
|
|
500
|
+
if (!isWorkflowTrigger(trigger) || trigger.kind !== "webhook") {
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
if (typeof trigger.name !== "string") {
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
const wire = nameToWire(trigger.name);
|
|
507
|
+
const owner = webhooks.get(wire);
|
|
508
|
+
if (owner !== undefined) {
|
|
509
|
+
report("workflow_webhook_duplicate", `workflows.${String(candidate.name)}.triggers.${trigger.name}`, `the webhook ${trigger.name} is also declared on ${owner}; a webhook's name is its URL, ` +
|
|
510
|
+
`/api/webhooks/${wire}, so it is unique across the ontology.`);
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
webhooks.set(wire, String(candidate.name));
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
if (webhooks.size > MAX_WEBHOOKS) {
|
|
518
|
+
report("workflow_webhook_limit_exceeded", "workflows", `An ontology may declare at most ${MAX_WEBHOOKS} webhooks; it declares ${webhooks.size}.`);
|
|
519
|
+
}
|
|
520
|
+
return resolved;
|
|
521
|
+
}
|
|
522
|
+
let deliveryJsonSchema;
|
|
523
|
+
/**
|
|
524
|
+
* The JSON Schema of `webhookDelivery`, the input every webhook-started workflow declares, as the
|
|
525
|
+
* manifest writes it. Publication compares a workflow's input with it.
|
|
526
|
+
*/
|
|
527
|
+
export function webhookDeliveryJsonSchema() {
|
|
528
|
+
deliveryJsonSchema ??= inputJsonSchema(actionInputToWire(webhookDelivery));
|
|
529
|
+
return deliveryJsonSchema;
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* A webhook trigger's manifest form, without its name: the signature when declared and the headers
|
|
533
|
+
* when listed. `undefined` after reporting a problem.
|
|
534
|
+
*/
|
|
535
|
+
function resolveWebhookTrigger(trigger, at, report) {
|
|
536
|
+
let valid = true;
|
|
537
|
+
const signature = trigger.signature;
|
|
538
|
+
if (signature !== undefined) {
|
|
539
|
+
const scheme = typeof signature === "object" && signature !== null
|
|
540
|
+
? signature.scheme
|
|
541
|
+
: undefined;
|
|
542
|
+
const secret = typeof signature === "object" && signature !== null
|
|
543
|
+
? signature.secret
|
|
544
|
+
: undefined;
|
|
545
|
+
if (!WEBHOOK_SIGNATURE_SCHEMES.includes(scheme)) {
|
|
546
|
+
report("workflow_webhook_signature_invalid", `${at}.signature.scheme`, `${JSON.stringify(scheme) ?? "undefined"} is not a signature scheme; use one of ` +
|
|
547
|
+
`${WEBHOOK_SIGNATURE_SCHEMES.join(", ")}, or omit signature for a provider that signs ` +
|
|
548
|
+
"nothing.");
|
|
549
|
+
valid = false;
|
|
550
|
+
}
|
|
551
|
+
if (typeof secret !== "string" || !WORKSPACE_SECRET_NAME.test(secret)) {
|
|
552
|
+
report("workflow_webhook_signature_invalid", `${at}.signature.secret`, `${JSON.stringify(secret) ?? "undefined"} is not a workspace secret name; name the ` +
|
|
553
|
+
"secret that holds the provider's signing secret, such as stripe-webhook-signing-secret.");
|
|
554
|
+
valid = false;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
const verification = trigger.verification;
|
|
558
|
+
if (verification !== undefined) {
|
|
559
|
+
const declared = verification;
|
|
560
|
+
if (typeof declared !== "object" ||
|
|
561
|
+
declared === null ||
|
|
562
|
+
declared.scheme !== "meta" ||
|
|
563
|
+
typeof declared.verifyToken !== "string" ||
|
|
564
|
+
!WORKSPACE_SECRET_NAME.test(declared.verifyToken) ||
|
|
565
|
+
trigger.signature?.scheme !== "meta") {
|
|
566
|
+
report("workflow_webhook_verification_invalid", `${at}.verification`, 'Use { scheme: "meta", verifyToken: <workspace secret name> } with a Meta signature.');
|
|
567
|
+
valid = false;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
const headers = trigger.headers;
|
|
571
|
+
if (!Array.isArray(headers) || headers.length > MAX_WEBHOOK_HEADERS) {
|
|
572
|
+
report("workflow_webhook_headers_invalid", `${at}.headers`, `headers is a list of at most ${MAX_WEBHOOK_HEADERS} lowercase header names.`);
|
|
573
|
+
valid = false;
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
const seen = new Set();
|
|
577
|
+
for (const header of headers) {
|
|
578
|
+
const problem = webhookHeaderProblem(header);
|
|
579
|
+
if (problem !== undefined) {
|
|
580
|
+
report("workflow_webhook_headers_invalid", `${at}.headers`, problem);
|
|
581
|
+
valid = false;
|
|
582
|
+
}
|
|
583
|
+
else if (seen.has(header)) {
|
|
584
|
+
report("workflow_webhook_headers_invalid", `${at}.headers`, `${String(header)} is listed twice.`);
|
|
585
|
+
valid = false;
|
|
586
|
+
}
|
|
587
|
+
else {
|
|
588
|
+
seen.add(header);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
if (!valid) {
|
|
593
|
+
return undefined;
|
|
594
|
+
}
|
|
595
|
+
const listed = trigger.headers;
|
|
596
|
+
return {
|
|
597
|
+
kind: "webhook",
|
|
598
|
+
name: trigger.name,
|
|
599
|
+
description: trigger.description,
|
|
600
|
+
...(trigger.signature === undefined
|
|
601
|
+
? {}
|
|
602
|
+
: { signature: { scheme: trigger.signature.scheme, secret: trigger.signature.secret } }),
|
|
603
|
+
...(trigger.verification === undefined ? {} : { verification: { ...trigger.verification } }),
|
|
604
|
+
...(listed.length === 0 ? {} : { headers: [...listed] }),
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* A cron trigger's manifest form, without its name: the expression normalized and the input in
|
|
609
|
+
* wire form. `undefined` after reporting a problem.
|
|
610
|
+
*/
|
|
611
|
+
function resolveCronTrigger(trigger, at, schema, wire, report) {
|
|
612
|
+
const problem = typeof trigger.cron === "string" ? cronProblem(trigger.cron) : "fields";
|
|
613
|
+
let valid = true;
|
|
614
|
+
if (problem !== undefined) {
|
|
615
|
+
report("workflow_cron_invalid", `${at}.cron`, `${JSON.stringify(trigger.cron) ?? "undefined"} is not a cron expression (${problem}): ` +
|
|
616
|
+
"write five fields in UTC with numbers, *, ranges, steps and lists, such as " +
|
|
617
|
+
"0 8 * * 1-5, and restrict at most one of day-of-month and day-of-week.");
|
|
618
|
+
valid = false;
|
|
619
|
+
}
|
|
620
|
+
if (problem === undefined && cronNeverFires(trigger.cron)) {
|
|
621
|
+
report("workflow_cron_never_fires", `${at}.cron`, `${trigger.cron} names no day that exists, such as the 31st of a 30-day month; it ` +
|
|
622
|
+
"never fires.", "warning");
|
|
623
|
+
}
|
|
624
|
+
const wireInput = typeof schema === "string" || wire === undefined
|
|
625
|
+
? undefined
|
|
626
|
+
: workflowWireInput(schema, wire, trigger.input);
|
|
627
|
+
if (typeof wireInput === "string") {
|
|
628
|
+
report("workflow_input_invalid", `${at}.input`, wireInput);
|
|
629
|
+
return undefined;
|
|
630
|
+
}
|
|
631
|
+
// An input schema without a wire form is reported on the workflow, which is then left out.
|
|
632
|
+
if (!valid || wireInput === undefined) {
|
|
633
|
+
return undefined;
|
|
634
|
+
}
|
|
635
|
+
return {
|
|
636
|
+
kind: "cron",
|
|
637
|
+
name: trigger.name,
|
|
638
|
+
description: trigger.description,
|
|
639
|
+
cron: trigger.cron.trim().split(/\s+/).join(" "),
|
|
640
|
+
input: wireInput,
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* An action trigger's manifest form, without its name: the entity's and the action's wire names.
|
|
645
|
+
* The target must be a handle of a declared stored entity or of one of its actions; `when` and
|
|
646
|
+
* `input` must be synchronous functions; without `input`, the workflow's input must accept `{}`.
|
|
647
|
+
* `undefined` after reporting a problem.
|
|
648
|
+
*/
|
|
649
|
+
function resolveActionTrigger(trigger, at, entities, schema, wire, report) {
|
|
650
|
+
let valid = true;
|
|
651
|
+
const target = trigger.target;
|
|
652
|
+
const isActionHandle = typeof target === "function" &&
|
|
653
|
+
isAction(target) &&
|
|
654
|
+
typeof target.entity === "string";
|
|
655
|
+
if (!isActionHandle && !isEntity(target)) {
|
|
656
|
+
report("invalid-trigger", `${at}.target`, "the target is an action handle, such as Lead.actions.changeStatus, or an entity handle, " +
|
|
657
|
+
"such as Lead.");
|
|
658
|
+
return undefined;
|
|
659
|
+
}
|
|
660
|
+
const owner = entities.find((candidate) => candidate.name === trigger.entity);
|
|
661
|
+
if (owner === undefined || owner.entity.definition.source !== undefined) {
|
|
662
|
+
report("invalid-trigger", `${at}.target`, owner === undefined
|
|
663
|
+
? `${JSON.stringify(trigger.entity)} is not an entity of this ontology; list it in ` +
|
|
664
|
+
"defineOntology({ entities })."
|
|
665
|
+
: `${owner.name} is warehouse-backed and has no actions; trigger on a stored entity.`);
|
|
666
|
+
valid = false;
|
|
667
|
+
}
|
|
668
|
+
else if (trigger.action !== undefined &&
|
|
669
|
+
!owner.actions.some((candidate) => candidate.name === trigger.action)) {
|
|
670
|
+
report("trigger_action_unknown", `${at}.target`, `${trigger.action} is not an action of ${owner.name}; name one of its declared actions, ` +
|
|
671
|
+
"or the entity for any of them.");
|
|
672
|
+
valid = false;
|
|
673
|
+
}
|
|
674
|
+
for (const key of ["when", "input"]) {
|
|
675
|
+
const fn = trigger[key];
|
|
676
|
+
if (fn === undefined) {
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
if (typeof fn !== "function") {
|
|
680
|
+
report("invalid-trigger", `${at}.${key}`, `${key} is a function of the event and the record, (event, { record, before, after }).`);
|
|
681
|
+
valid = false;
|
|
682
|
+
}
|
|
683
|
+
else if (isAsyncFunction(fn)) {
|
|
684
|
+
report("trigger_function_async", `${at}.${key}`, `${key} runs inside the action's transaction, so it is synchronous: drop async and ` +
|
|
685
|
+
"read only the event and the record; the workflow's steps read the rest.");
|
|
686
|
+
valid = false;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
if (trigger.input === undefined && typeof schema !== "string" && wire !== undefined) {
|
|
690
|
+
const empty = workflowWireInput(schema, wire, undefined);
|
|
691
|
+
if (typeof empty === "string") {
|
|
692
|
+
report("workflow_input_invalid", `${at}.input`, `without input the trigger starts the workflow with {}, which ${empty}; map the event ` +
|
|
693
|
+
"to the workflow's input with input: (event, record) => ({ … }).");
|
|
694
|
+
valid = false;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
return valid && owner !== undefined
|
|
698
|
+
? {
|
|
699
|
+
kind: "action",
|
|
700
|
+
name: trigger.name,
|
|
701
|
+
description: trigger.description,
|
|
702
|
+
entity: owner.name,
|
|
703
|
+
...(trigger.action === undefined ? {} : { action: trigger.action }),
|
|
704
|
+
}
|
|
705
|
+
: undefined;
|
|
706
|
+
}
|
|
707
|
+
/** The keys of the connections a workflow's steps call, each listed in `defineOntology`. */
|
|
708
|
+
function workflowConnections(path, declared, scope) {
|
|
709
|
+
if (declared === undefined) {
|
|
710
|
+
return [];
|
|
711
|
+
}
|
|
712
|
+
if (!Array.isArray(declared)) {
|
|
713
|
+
scope.report("invalid-connection", `${path}.connections`, "connections is a list of connection(…) handles.");
|
|
714
|
+
return undefined;
|
|
715
|
+
}
|
|
716
|
+
const keys = [];
|
|
717
|
+
let valid = true;
|
|
718
|
+
declared.forEach((candidate, index) => {
|
|
719
|
+
const at = `${path}.connections[${index}]`;
|
|
720
|
+
if (!isConnection(candidate)) {
|
|
721
|
+
scope.report("invalid-connection", at, "is not a connection; declare it with connection(key, { … }).");
|
|
722
|
+
valid = false;
|
|
723
|
+
}
|
|
724
|
+
else if (!scope.connections.has(candidate.key)) {
|
|
725
|
+
scope.report("unknown-connection", at, `the connection ${String(candidate.key)} is not listed in defineOntology({ connections }).`);
|
|
726
|
+
valid = false;
|
|
727
|
+
}
|
|
728
|
+
else if (keys.includes(candidate.key)) {
|
|
729
|
+
scope.report("duplicate-name", at, `the connection ${candidate.key} is listed twice.`);
|
|
730
|
+
valid = false;
|
|
731
|
+
}
|
|
732
|
+
else {
|
|
733
|
+
keys.push(candidate.key);
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
return valid ? keys : undefined;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* A trigger's input as the cron route sends it: decoded with the workflow's input schema as
|
|
740
|
+
* declared, then encoded with its wire form. A message when it does not decode.
|
|
741
|
+
*/
|
|
742
|
+
function workflowWireInput(authored, wire, value) {
|
|
743
|
+
if (value !== undefined && !isPlainObject(value)) {
|
|
744
|
+
return "a workflow's input is an object of its fields.";
|
|
745
|
+
}
|
|
746
|
+
const decoded = validate(authored, value === undefined ? {} : value);
|
|
747
|
+
if (!decoded.ok) {
|
|
748
|
+
return ("does not decode with the workflow's input: " +
|
|
749
|
+
decoded.issues
|
|
750
|
+
.map((issue) => `${issue.path.length > 0 ? issue.path.join(".") : "input"}: ${issue.message}`)
|
|
751
|
+
.join("; "));
|
|
752
|
+
}
|
|
753
|
+
const encoded = Schema.encodeUnknownResult(wire)(decoded.value);
|
|
754
|
+
if (Result.isFailure(encoded)) {
|
|
755
|
+
return `has no wire form: ${errorMessage(encoded.failure)}`;
|
|
756
|
+
}
|
|
757
|
+
return encoded.success;
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* The operations of a stored entity that become MCP tools, each with the path a problem is reported
|
|
761
|
+
* at: the reads, the generated actions and the authored actions.
|
|
762
|
+
*/
|
|
763
|
+
function toolOperations(candidate) {
|
|
764
|
+
const { permissions } = candidate;
|
|
765
|
+
const generated = [
|
|
766
|
+
...["create", "update", "delete"].filter((verb) => permissions[verb] !== undefined),
|
|
767
|
+
...(permissions.create !== undefined
|
|
768
|
+
? candidate.properties
|
|
769
|
+
.filter(({ definition }) => definition.unique)
|
|
770
|
+
.map(({ name }) => `${GENERATED_UPSERT_PREFIX}${name}`)
|
|
771
|
+
: []),
|
|
772
|
+
...(permissions.link !== undefined ? ["link", "unlink"] : []),
|
|
773
|
+
...(candidate.builtIn && candidate.name === User.name ? USER_MEMBER_VERBS : []),
|
|
774
|
+
];
|
|
775
|
+
return [
|
|
776
|
+
...[...MCP_READ_TOOLS, ...generated].map((operation) => ({ operation, path: candidate.name })),
|
|
777
|
+
// An authored action with a generated action's name is already refused as a reserved name.
|
|
778
|
+
...candidate.actions
|
|
779
|
+
.filter(({ name }) => !GENERATED_ACTIONS.includes(name) &&
|
|
780
|
+
!GENERATED_UPSERT.test(name) &&
|
|
781
|
+
!generated.includes(name))
|
|
782
|
+
.map(({ name }) => ({ operation: name, path: `${candidate.name}.actions.${name}` })),
|
|
783
|
+
];
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Every MCP tool needs a name of its own, at most 64 characters long. A tool's name joins the
|
|
787
|
+
* entity's and the operation's with an underscore, so entity `pilot` with an action `request_find`
|
|
788
|
+
* would take the name of entity `pilot_request`'s `find`.
|
|
789
|
+
*/
|
|
790
|
+
function checkToolNames(entities, report) {
|
|
791
|
+
const owners = new Map(MCP_METRIC_TOOLS.map((name) => [name, "the ontology's metric tools"]));
|
|
792
|
+
const seen = new Set();
|
|
793
|
+
for (const candidate of entities) {
|
|
794
|
+
// A second entity with the same name is already refused as a duplicate.
|
|
795
|
+
if (candidate.entity.definition.source !== undefined || seen.has(candidate.name)) {
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
seen.add(candidate.name);
|
|
799
|
+
for (const { operation, path } of toolOperations(candidate)) {
|
|
800
|
+
const name = mcpToolName(candidate.name, operation);
|
|
801
|
+
const owner = owners.get(name);
|
|
802
|
+
if (owner !== undefined) {
|
|
803
|
+
report("invalid-tool-name", path, `its MCP tool ${name} is already the tool of ${owner}; rename one of them.`);
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
owners.set(name, `${candidate.name}'s ${operation}`);
|
|
807
|
+
if (name.length > MCP_TOOL_NAME_MAX_LENGTH) {
|
|
808
|
+
report("invalid-tool-name", path, `its MCP tool ${name} is ${name.length} characters long; agents accept at most ` +
|
|
809
|
+
`${MCP_TOOL_NAME_MAX_LENGTH}. Shorten the entity or the action name.`);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
/** The handles `defineOntology({ connections })` lists, each with a valid key and a description. */
|
|
815
|
+
function collectConnections(config, report) {
|
|
816
|
+
const declared = config.connections;
|
|
817
|
+
if (declared === undefined) {
|
|
818
|
+
return [];
|
|
819
|
+
}
|
|
820
|
+
if (!Array.isArray(declared)) {
|
|
821
|
+
report("invalid-connection", "connections", "connections is a list of connection(…) handles.");
|
|
822
|
+
return [];
|
|
823
|
+
}
|
|
824
|
+
const connections = [];
|
|
825
|
+
const keys = new Set();
|
|
826
|
+
declared.forEach((candidate, index) => {
|
|
827
|
+
const path = `connections[${index}]`;
|
|
828
|
+
if (!isConnection(candidate)) {
|
|
829
|
+
report("invalid-connection", path, "is not a connection; declare it with connection(key, { … }).");
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
if (typeof candidate.key !== "string" || !CONNECTION_KEY.test(candidate.key)) {
|
|
833
|
+
report("invalid-connection", path, `${JSON.stringify(candidate.key)} is not a connection key: lowercase letters, digits and ` +
|
|
834
|
+
"underscores, starting with a letter, as connections/connections.py declares it.");
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
if (keys.has(candidate.key)) {
|
|
838
|
+
report("duplicate-name", path, `the connection ${candidate.key} is listed twice.`);
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
if (isBlank(candidate.description)) {
|
|
842
|
+
report("missing-description", path, `the connection ${candidate.key} needs a description of what the ontology uses it for.`);
|
|
843
|
+
}
|
|
844
|
+
keys.add(candidate.key);
|
|
845
|
+
connections.push({
|
|
846
|
+
key: candidate.key,
|
|
847
|
+
description: typeof candidate.description === "string" ? candidate.description : "",
|
|
848
|
+
delivers: candidate.delivers === true,
|
|
849
|
+
handle: candidate,
|
|
850
|
+
});
|
|
851
|
+
});
|
|
852
|
+
return connections;
|
|
853
|
+
}
|
|
854
|
+
/** A listed connection that no action calls is a leftover: it grants the deployment for nothing. */
|
|
855
|
+
function checkConnectionsUsed(entities, connections, declared, workflows, report) {
|
|
856
|
+
const used = new Set([
|
|
857
|
+
...entities.flatMap((entity) => entity.actions.flatMap((candidate) => candidate.connections)),
|
|
858
|
+
...workflows.flatMap((candidate) => candidate.connections),
|
|
859
|
+
]);
|
|
860
|
+
connections.forEach((candidate) => {
|
|
861
|
+
if (!used.has(candidate.key)) {
|
|
862
|
+
report("unused-connection", `connections[${declared.indexOf(candidate.handle)}]`, `no action or workflow calls the connection ${candidate.key}; list it in an action's ` +
|
|
863
|
+
"or a workflow's connections or remove it.");
|
|
864
|
+
}
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* The keys of an action's connections, each listed in `defineOntology({ connections })`, and the
|
|
869
|
+
* phases they require: `prepare` with connections and only with them, except for built-in actions,
|
|
870
|
+
* and `prepare` or `handler` always.
|
|
871
|
+
*/
|
|
872
|
+
function resolveActionConnections(path, action, scope, builtIn) {
|
|
873
|
+
const { report } = scope;
|
|
874
|
+
const declared = action.connections;
|
|
875
|
+
const keys = [];
|
|
876
|
+
if (!Array.isArray(declared)) {
|
|
877
|
+
report("invalid-connection", `${path}.connections`, "connections is a list of connection(…) handles.");
|
|
878
|
+
}
|
|
879
|
+
else {
|
|
880
|
+
declared.forEach((candidate, index) => {
|
|
881
|
+
const at = `${path}.connections[${index}]`;
|
|
882
|
+
if (!isConnection(candidate)) {
|
|
883
|
+
report("invalid-connection", at, "is not a connection; declare it with connection(key, { … }).");
|
|
884
|
+
}
|
|
885
|
+
else if (!scope.connections.has(candidate.key)) {
|
|
886
|
+
report("unknown-connection", at, `the connection ${String(candidate.key)} is not listed in defineOntology({ connections }).`);
|
|
887
|
+
}
|
|
888
|
+
else if (keys.includes(candidate.key)) {
|
|
889
|
+
report("duplicate-name", at, `the connection ${candidate.key} is listed twice.`);
|
|
890
|
+
}
|
|
891
|
+
else {
|
|
892
|
+
keys.push(candidate.key);
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
const hasPrepare = typeof action.prepare === "function";
|
|
897
|
+
const hasHandler = typeof action.handler === "function";
|
|
898
|
+
if (Array.isArray(declared) && declared.length > 0 && !hasPrepare) {
|
|
899
|
+
report("invalid-prepare", `${path}.prepare`, "an action with connections calls them in prepare, which runs before the transaction.");
|
|
900
|
+
}
|
|
901
|
+
// A built-in action's prepare reaches the platform, such as the workspace's agent, instead.
|
|
902
|
+
if (hasPrepare &&
|
|
903
|
+
!builtIn &&
|
|
904
|
+
Array.isArray(declared) &&
|
|
905
|
+
declared.length === 0 &&
|
|
906
|
+
action.files === undefined) {
|
|
907
|
+
report("invalid-prepare", `${path}.prepare`, "prepare requires connections or file access; declare files: [] for file reads.");
|
|
908
|
+
}
|
|
909
|
+
if (!hasPrepare && !hasHandler) {
|
|
910
|
+
report("invalid-action", `${path}.handler`, "every action needs a handler.");
|
|
911
|
+
}
|
|
912
|
+
return keys;
|
|
913
|
+
}
|
|
914
|
+
function collectEntities(config, builtIns, report) {
|
|
915
|
+
const entities = [...builtIns];
|
|
916
|
+
(config.entities ?? []).forEach((candidate, index) => {
|
|
917
|
+
if (!isEntity(candidate)) {
|
|
918
|
+
report("invalid-entity", `entities[${index}]`, "is not an entity; declare entities with entity(name, { … }).");
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
const listed = entities.findIndex((existing) => existing.definition === candidate.definition);
|
|
922
|
+
if (listed === -1) {
|
|
923
|
+
entities.push(candidate);
|
|
924
|
+
}
|
|
925
|
+
else if (isDraft(entities[listed]) && !isDraft(candidate)) {
|
|
926
|
+
// The same entity, listed before and after `.actions(…)`: keep the form with actions.
|
|
927
|
+
entities[listed] = candidate;
|
|
928
|
+
}
|
|
929
|
+
});
|
|
930
|
+
return entities;
|
|
931
|
+
}
|
|
932
|
+
// @legacy ontology.removed-allow-option
|
|
933
|
+
const REMOVED_ALLOW = "allow was removed: each action requires one permission (its permission option, by default " +
|
|
934
|
+
"<entity>:<action name>), and roles grant permissions in their permissions list.";
|
|
935
|
+
/** The built-in `member` role with the ontology's member permissions. */
|
|
936
|
+
function memberRole(config, report) {
|
|
937
|
+
const options = config.member;
|
|
938
|
+
if (options === undefined) {
|
|
939
|
+
return member;
|
|
940
|
+
}
|
|
941
|
+
if (!isPlainObject(options)) {
|
|
942
|
+
report("missing-permissions", "member", 'member takes { permissions: ["*:read", …] }.');
|
|
943
|
+
return member;
|
|
944
|
+
}
|
|
945
|
+
if ("allow" in options) {
|
|
946
|
+
report("removed-allow", "member.allow", REMOVED_ALLOW);
|
|
947
|
+
}
|
|
948
|
+
return role(MEMBER_ROLE, {
|
|
949
|
+
description: member.description,
|
|
950
|
+
permissions: options.permissions,
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
/** The sign-in policy, which decides whether a stranger who signs in becomes a member. */
|
|
954
|
+
function signInOptions(config, report) {
|
|
955
|
+
const options = config.signIn;
|
|
956
|
+
const fallback = { policy: DEFAULT_SIGN_IN_POLICY };
|
|
957
|
+
if (options === undefined) {
|
|
958
|
+
return fallback;
|
|
959
|
+
}
|
|
960
|
+
if (!isPlainObject(options)) {
|
|
961
|
+
report("invalid-sign-in", "signIn", 'signIn takes { policy: "invited" } or { policy: "open" }.');
|
|
962
|
+
return fallback;
|
|
963
|
+
}
|
|
964
|
+
for (const key of Object.keys(options)) {
|
|
965
|
+
if (!["policy", "channels", "onNewUser", "browser", "codeLink"].includes(key)) {
|
|
966
|
+
report("invalid-sign-in", `signIn.${key}`, `Unknown signIn key: ${key}.`);
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
if (!isSignInPolicy(options.policy)) {
|
|
970
|
+
report("invalid-sign-in", "signIn.policy", `${JSON.stringify(options.policy) ?? "undefined"} is not a sign-in policy; use "invited" ` +
|
|
971
|
+
'(only people invited as members) or "open" (anyone who signs in becomes a member).');
|
|
972
|
+
return fallback;
|
|
973
|
+
}
|
|
974
|
+
const channels = {};
|
|
975
|
+
if (options.channels !== undefined) {
|
|
976
|
+
if (!isPlainObject(options.channels)) {
|
|
977
|
+
report("invalid-sign-in", "signIn.channels", "channels must map providers to admission policies.");
|
|
978
|
+
}
|
|
979
|
+
else {
|
|
980
|
+
for (const [provider, policy] of Object.entries(options.channels)) {
|
|
981
|
+
if (!CHANNEL_PROVIDERS.includes(provider)) {
|
|
982
|
+
report("invalid-sign-in", `signIn.channels.${provider}`, `Unsupported channel provider: ${provider}.`);
|
|
983
|
+
}
|
|
984
|
+
else if (isPlainObject(policy)) {
|
|
985
|
+
report("invalid-sign-in", `signIn.channels.${provider}`, "The object form is reserved; use a policy string.");
|
|
986
|
+
}
|
|
987
|
+
else if (!isSignInPolicy(policy)) {
|
|
988
|
+
report("invalid-sign-in", `signIn.channels.${provider}`, 'Use "open" or "invited".');
|
|
989
|
+
}
|
|
990
|
+
else {
|
|
991
|
+
const adapters = config.chat;
|
|
992
|
+
if (adapters?.[provider] === undefined) {
|
|
993
|
+
report("invalid-sign-in", `signIn.channels.${provider}`, `Configure a matching chat.${provider} adapter.`);
|
|
994
|
+
}
|
|
995
|
+
channels[provider] = policy;
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
const browserError = options.browser === undefined ? undefined : validateBrowserSignIn(options.browser);
|
|
1001
|
+
if (browserError) {
|
|
1002
|
+
report("invalid-sign-in", "signIn.browser", browserError);
|
|
1003
|
+
}
|
|
1004
|
+
const hook = options.onNewUser;
|
|
1005
|
+
if (hook !== undefined &&
|
|
1006
|
+
(typeof hook !== "string" || !parsePermission(hook) || hook.includes("*"))) {
|
|
1007
|
+
report("invalid-sign-in", "signIn.onNewUser", "Use an action name: <entity>:<action>.");
|
|
1008
|
+
}
|
|
1009
|
+
const codeLink = options.codeLink;
|
|
1010
|
+
if (codeLink !== undefined && typeof codeLink !== "boolean") {
|
|
1011
|
+
report("invalid-sign-in", "signIn.codeLink", "codeLink takes true or false.");
|
|
1012
|
+
}
|
|
1013
|
+
return {
|
|
1014
|
+
policy: options.policy,
|
|
1015
|
+
...(options.browser === undefined || browserError
|
|
1016
|
+
? {}
|
|
1017
|
+
: { browser: options.browser }),
|
|
1018
|
+
...(Object.keys(channels).length > 0 ? { channels } : {}),
|
|
1019
|
+
...(typeof hook === "string" ? { onNewUser: hook } : {}),
|
|
1020
|
+
...(typeof codeLink === "boolean" ? { codeLink } : {}),
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
function collectRoles(config, report) {
|
|
1024
|
+
const roles = [memberRole(config, report)];
|
|
1025
|
+
(config.roles ?? []).forEach((candidate, index) => {
|
|
1026
|
+
if (!isRole(candidate)) {
|
|
1027
|
+
report("invalid-role", `roles[${index}]`, "is not a role; declare roles with role(name, { description, permissions }).");
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
// Listing the built-in role is harmless: its permissions come from defineOntology({ member }).
|
|
1031
|
+
if (candidate === member || roles.includes(candidate)) {
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
const path = `roles.${candidate.name}`;
|
|
1035
|
+
if (candidate.name === MEMBER_ROLE || candidate.name === FEEDBACK_REPORTER_ROLE) {
|
|
1036
|
+
report("reserved-name", path, `${candidate.name} is a reserved built-in role.`);
|
|
1037
|
+
return;
|
|
1038
|
+
}
|
|
1039
|
+
if (roles.some((existing) => existing.name === candidate.name)) {
|
|
1040
|
+
report("duplicate-name", path, `role "${candidate.name}" is declared twice.`);
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
if (!SNAKE_CASE_NAME.test(candidate.name)) {
|
|
1044
|
+
report("invalid-name", path, "role names are lowercase snake_case, for example sales_lead.");
|
|
1045
|
+
}
|
|
1046
|
+
if (isBlank(candidate.description)) {
|
|
1047
|
+
report("missing-description", path, "every role needs a description.");
|
|
1048
|
+
}
|
|
1049
|
+
if ("allow" in candidate) {
|
|
1050
|
+
report("removed-allow", `${path}.allow`, REMOVED_ALLOW);
|
|
1051
|
+
}
|
|
1052
|
+
roles.push(candidate);
|
|
1053
|
+
});
|
|
1054
|
+
return roles;
|
|
1055
|
+
}
|
|
1056
|
+
function resolveEntity(candidate, scope, resolvedProperties) {
|
|
1057
|
+
const { report } = scope;
|
|
1058
|
+
const { definition, name } = candidate;
|
|
1059
|
+
if (!SNAKE_CASE_NAME.test(name)) {
|
|
1060
|
+
report("invalid-name", name, "entity names are lowercase snake_case, for example pilot_request.");
|
|
1061
|
+
}
|
|
1062
|
+
else if (name.endsWith(HISTORY_SUFFIX)) {
|
|
1063
|
+
report("reserved-name", name, `names ending in ${HISTORY_SUFFIX} are reserved for history tables.`);
|
|
1064
|
+
}
|
|
1065
|
+
else if (name === QUERY_METRICS_ROUTE || name === EARLIER_QUERY_METRICS_ROUTE) {
|
|
1066
|
+
report("reserved-name", name, `/api/ontology/${name} answers metric queries across the ontology's entities; ` +
|
|
1067
|
+
"choose another entity name.");
|
|
1068
|
+
}
|
|
1069
|
+
else if (RESERVED_ENTITY_NAMES.includes(name)) {
|
|
1070
|
+
report("reserved-name", name, `/api/ontology/${name} is a route of the ontology service.`);
|
|
1071
|
+
}
|
|
1072
|
+
else if (APP_FEEDBACK_ENTITIES.some((feedback) => feedback.name === name) &&
|
|
1073
|
+
!scope.builtIns.has(definition)) {
|
|
1074
|
+
report("reserved-name", name, `${name} is the SDK's app feedback; configure feedback on defineOntology ` +
|
|
1075
|
+
"instead of declaring or listing it.");
|
|
1076
|
+
}
|
|
1077
|
+
if (isBlank(definition.label) || isBlank(definition.plural)) {
|
|
1078
|
+
report("missing-label", name, "every entity needs a label and a plural.");
|
|
1079
|
+
}
|
|
1080
|
+
if (isBlank(definition.description)) {
|
|
1081
|
+
report("missing-description", name, "every entity needs a description.");
|
|
1082
|
+
}
|
|
1083
|
+
if (definition.guide !== undefined && !/^(?!\/)[^\s]*\.md$/.test(definition.guide)) {
|
|
1084
|
+
report("invalid-guide", `${name}.guide`, "must be a relative path to a Markdown file, such as ./lead.md.");
|
|
1085
|
+
}
|
|
1086
|
+
checkWrites(name, definition, report);
|
|
1087
|
+
const properties = resolvedProperties ?? resolveProperties(candidate, scope);
|
|
1088
|
+
const relationships = resolveRelationships(candidate, scope);
|
|
1089
|
+
if (isBlank(definition.title)) {
|
|
1090
|
+
report("invalid-title", `${name}.title`, "every entity needs a title property that names its records.");
|
|
1091
|
+
}
|
|
1092
|
+
else if (!properties.some((property) => property.name === definition.title)) {
|
|
1093
|
+
report("invalid-title", `${name}.title`, `"${definition.title}" is not a declared property of ${name}.`);
|
|
1094
|
+
}
|
|
1095
|
+
const builtIn = scope.builtIns.has(candidate.definition);
|
|
1096
|
+
const writes = writesOf(candidate);
|
|
1097
|
+
if (writes === "generated" && !builtIn && definition.source === undefined) {
|
|
1098
|
+
checkGeneratedUpdate(name, properties, relationships, report);
|
|
1099
|
+
}
|
|
1100
|
+
return {
|
|
1101
|
+
entity: candidate,
|
|
1102
|
+
name,
|
|
1103
|
+
builtIn,
|
|
1104
|
+
properties,
|
|
1105
|
+
relationships,
|
|
1106
|
+
indexes: resolveIndexes(candidate, properties, relationships, scope),
|
|
1107
|
+
actions: resolveActions(candidate, scope),
|
|
1108
|
+
permissions: entityPermissions(name, builtIn || definition.source !== undefined, writes, relationships, scope),
|
|
1109
|
+
writes,
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Whether a `rel.many` to `target` keeps its generated `link` and `unlink`: not when the target
|
|
1114
|
+
* declares `writes: "actions"`, whose links change only through authored actions on either side.
|
|
1115
|
+
*/
|
|
1116
|
+
function generatedLinkTarget(target, scope) {
|
|
1117
|
+
const candidate = target === undefined ? undefined : scope.entitiesByName.get(target);
|
|
1118
|
+
return candidate === undefined || writesOf(candidate) === "generated";
|
|
1119
|
+
}
|
|
1120
|
+
/** Who writes an entity's records: `"actions"` only when it declares it, as `check` accepts it. */
|
|
1121
|
+
function writesOf(candidate) {
|
|
1122
|
+
return candidate.definition.writes === "actions" && candidate.definition.source === undefined
|
|
1123
|
+
? "actions"
|
|
1124
|
+
: "generated";
|
|
1125
|
+
}
|
|
1126
|
+
function checkWrites(name, definition, report) {
|
|
1127
|
+
const writes = definition.writes;
|
|
1128
|
+
if (writes === undefined) {
|
|
1129
|
+
return;
|
|
1130
|
+
}
|
|
1131
|
+
if (writes !== "generated" && writes !== "actions") {
|
|
1132
|
+
report("invalid-writes", `${name}.writes`, 'writes is "generated", the default, or "actions", when only the entity\'s own actions ' +
|
|
1133
|
+
"write its records.");
|
|
1134
|
+
}
|
|
1135
|
+
else if (definition.source !== undefined) {
|
|
1136
|
+
report("invalid-writes", `${name}.writes`, "a warehouse-backed entity has no writes; remove writes.");
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Warns when the generated `update` could change nothing: every property is `managed` and the
|
|
1141
|
+
* entity holds no `rel.one` key or `rel.many` list to change. The tool is listed for every caller
|
|
1142
|
+
* with `update` and does nothing; `writes: "actions"` removes it, with the other generated writes.
|
|
1143
|
+
*/
|
|
1144
|
+
function checkGeneratedUpdate(name, properties, relationships, report) {
|
|
1145
|
+
const editable = properties.some((property) => property.definition.managed !== true);
|
|
1146
|
+
const declared = relationships.some(({ relationship }) => !("on" in relationship && relationship.on !== undefined));
|
|
1147
|
+
if (editable || declared) {
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
report("generated-update-empty", `${name}.writes`, `every property of ${name} is managed and it declares no relationship, so its generated ` +
|
|
1151
|
+
`update changes nothing. Declare writes: "actions" when only its actions write its records.`, "warning");
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Reads need `<entity>:read`; each generated action needs the permission named after it. Built-in
|
|
1155
|
+
* entities hold the platform's records: `agent_chat` has one generated action, `update`, for the
|
|
1156
|
+
* properties members edit, and `user` the member actions of `USER_MEMBER_VERBS`, which are not
|
|
1157
|
+
* listed here. An entity that declares `writes: "actions"` has only `read`. `link` and `unlink`
|
|
1158
|
+
* exist with a `rel.many` whose two ends both keep their generated writes.
|
|
1159
|
+
*/
|
|
1160
|
+
function entityPermissions(name, builtIn, writes, relationships, scope) {
|
|
1161
|
+
const read = permissionOf(name, READ_VERB);
|
|
1162
|
+
if (builtIn) {
|
|
1163
|
+
return name === AgentChat.name ? { read, update: permissionOf(name, "update") } : { read };
|
|
1164
|
+
}
|
|
1165
|
+
if (writes === "actions") {
|
|
1166
|
+
return { read };
|
|
1167
|
+
}
|
|
1168
|
+
const many = relationships.some(({ relationship, target }) => relationship.cardinality === "many" && generatedLinkTarget(target, scope));
|
|
1169
|
+
return {
|
|
1170
|
+
read,
|
|
1171
|
+
create: permissionOf(name, "create"),
|
|
1172
|
+
update: permissionOf(name, "update"),
|
|
1173
|
+
delete: permissionOf(name, "delete"),
|
|
1174
|
+
...(many ? { link: permissionOf(name, "link"), unlink: permissionOf(name, "unlink") } : {}),
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
function checkMemberName(kind, path, name, report) {
|
|
1178
|
+
if (!SNAKE_CASE_NAME.test(name)) {
|
|
1179
|
+
report("invalid-name", path, `${kind} names are lowercase snake_case, for example owned_leads.`);
|
|
1180
|
+
}
|
|
1181
|
+
else if (isSystemProperty(name)) {
|
|
1182
|
+
report("reserved-name", path, `${name} is a system property of every entity.`);
|
|
1183
|
+
}
|
|
1184
|
+
else if (RESERVED_MEMBER_NAMES.includes(name)) {
|
|
1185
|
+
report("reserved-name", path, `${name} is a combinator of the query language.`);
|
|
1186
|
+
}
|
|
1187
|
+
else if (kind !== "property" &&
|
|
1188
|
+
RESERVED_RELATIONSHIP_NAMES.includes(name)) {
|
|
1189
|
+
report("reserved-name", path, `${name} is the route of a record's history.`);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
/** Reports the options a property constructor was given but does not take. */
|
|
1193
|
+
function checkPropertyOptions(path, schema, report) {
|
|
1194
|
+
const unknown = unknownPropertyOptions(schema);
|
|
1195
|
+
const definition = propertyDefinition(schema);
|
|
1196
|
+
if (unknown.length > 0 && definition !== undefined) {
|
|
1197
|
+
report("invalid-property", path, `p.${definition.type} does not take ${unknown.join(", ")}; it takes ` +
|
|
1198
|
+
`${propertyOptionNames(definition.type).join(", ")}.`);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
function resolveProperties(candidate, scope) {
|
|
1202
|
+
const { report } = scope;
|
|
1203
|
+
const entries = Object.entries(candidate.definition.properties ?? {});
|
|
1204
|
+
const names = new Set(entries.map(([name]) => name));
|
|
1205
|
+
const properties = [];
|
|
1206
|
+
for (const [name, schema] of entries) {
|
|
1207
|
+
const path = `${candidate.name}.properties.${name}`;
|
|
1208
|
+
const originalDefinition = propertyDefinition(schema);
|
|
1209
|
+
const definition = !Array.isArray(originalDefinition?.scope)
|
|
1210
|
+
? originalDefinition
|
|
1211
|
+
: { ...originalDefinition, scope: originalDefinition.scope.map(nameToWire) };
|
|
1212
|
+
checkMemberName("property", path, name, report);
|
|
1213
|
+
checkPropertyOptions(path, schema, report);
|
|
1214
|
+
if (definition === undefined) {
|
|
1215
|
+
const hint = referenceTarget(schema) === undefined
|
|
1216
|
+
? "create properties with p.text(), p.select() and the other p constructors."
|
|
1217
|
+
: "references are for action inputs; declare relationships with rel.one or rel.many.";
|
|
1218
|
+
report("invalid-property", path, `is not a property; ${hint}`);
|
|
1219
|
+
continue;
|
|
1220
|
+
}
|
|
1221
|
+
checkProperty(path, schema, definition, report);
|
|
1222
|
+
if (definition.type === "list" &&
|
|
1223
|
+
!(scope.builtIns.has(candidate.definition) && candidate.name === User.name)) {
|
|
1224
|
+
report("invalid-property", path, "p.list is kept by the platform and only the built-in user has one, roles; " +
|
|
1225
|
+
"declare a rel.many to another entity, or a p.json property, for several values.");
|
|
1226
|
+
}
|
|
1227
|
+
if (definition.renamedFrom !== undefined) {
|
|
1228
|
+
// @legacy ontology.camelcase-member-names
|
|
1229
|
+
checkRenamedFrom(path, name, definition.renamedFrom, names, LEGACY_MEMBER_NAME, report);
|
|
1230
|
+
}
|
|
1231
|
+
properties.push({ name, schema: schema, definition });
|
|
1232
|
+
}
|
|
1233
|
+
return properties;
|
|
1234
|
+
}
|
|
1235
|
+
function checkProperty(path, schema, definition, report) {
|
|
1236
|
+
if (definition.type === "blob") {
|
|
1237
|
+
if (definition.unique || "default" in definition) {
|
|
1238
|
+
report("invalid-property", path, "Blob properties cannot be unique or have a default.");
|
|
1239
|
+
}
|
|
1240
|
+
if (!definition.accept?.length ||
|
|
1241
|
+
definition.accept.some((type) => !isBlobAccept(type)) ||
|
|
1242
|
+
new Set(definition.accept.map((type) => type.toLowerCase())).size !==
|
|
1243
|
+
definition.accept.length ||
|
|
1244
|
+
!Number.isInteger(definition.maxBytes) ||
|
|
1245
|
+
definition.maxBytes < 1 ||
|
|
1246
|
+
definition.maxBytes > MAX_BLOB_BYTES) {
|
|
1247
|
+
report("invalid-constraint", path, "Use MIME types or ranges and a limit from 1 byte to 250 MiB.");
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
if (definition.type === "select") {
|
|
1251
|
+
checkSelectOptions(path, definition, report);
|
|
1252
|
+
}
|
|
1253
|
+
if (definition.min !== undefined &&
|
|
1254
|
+
definition.max !== undefined &&
|
|
1255
|
+
definition.min > definition.max) {
|
|
1256
|
+
report("invalid-constraint", path, `min (${definition.min}) is greater than max (${definition.max}).`);
|
|
1257
|
+
}
|
|
1258
|
+
if (definition.maxLength !== undefined &&
|
|
1259
|
+
(!Number.isInteger(definition.maxLength) || definition.maxLength < 1)) {
|
|
1260
|
+
report("invalid-constraint", path, "maxLength must be a positive integer.");
|
|
1261
|
+
}
|
|
1262
|
+
if ("default" in definition && !Schema.is(valueSchema(schema))(definition.default)) {
|
|
1263
|
+
report("invalid-default", path, `the default ${JSON.stringify(definition.default)} is not a valid value.`);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
function checkSelectOptions(path, definition, report) {
|
|
1267
|
+
const options = definition.options ?? [];
|
|
1268
|
+
const seen = new Set();
|
|
1269
|
+
if (options.length === 0) {
|
|
1270
|
+
report("invalid-options", path, "a select property needs at least one option.");
|
|
1271
|
+
}
|
|
1272
|
+
options.forEach((option, index) => {
|
|
1273
|
+
const optionPath = `${path}.options[${index}]`;
|
|
1274
|
+
if (!SNAKE_CASE_NAME.test(option.value)) {
|
|
1275
|
+
report("invalid-options", optionPath, "option values must be lowercase snake_case.");
|
|
1276
|
+
}
|
|
1277
|
+
else if (seen.has(option.value)) {
|
|
1278
|
+
report("invalid-options", optionPath, `the option value "${option.value}" is listed twice.`);
|
|
1279
|
+
}
|
|
1280
|
+
seen.add(option.value);
|
|
1281
|
+
if (isBlank(option.description)) {
|
|
1282
|
+
report("missing-description", optionPath, `option "${option.value}" needs a description: { value, label, description }.`);
|
|
1283
|
+
}
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
/** The schema of a property's value, without the optional-key wrapper. */
|
|
1287
|
+
function valueSchema(schema) {
|
|
1288
|
+
return "schema" in schema && Schema.isSchema(schema.schema) ? schema.schema : schema;
|
|
1289
|
+
}
|
|
1290
|
+
function checkRenamedFrom(path, name, renamedFrom, currentNames, pattern, report) {
|
|
1291
|
+
if (!pattern.test(renamedFrom)) {
|
|
1292
|
+
report("invalid-renamed-from", path, `renamedFrom "${renamedFrom}" is not a valid name.`);
|
|
1293
|
+
}
|
|
1294
|
+
else if (renamedFrom === name) {
|
|
1295
|
+
report("invalid-renamed-from", path, "renamedFrom must be the previous name, not the current one.");
|
|
1296
|
+
}
|
|
1297
|
+
else if (currentNames.has(renamedFrom)) {
|
|
1298
|
+
report("invalid-renamed-from", path, `renamedFrom "${renamedFrom}" is a current name; a rename must free the old name.`);
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
function resolveTarget(target, scope) {
|
|
1302
|
+
let candidate = target;
|
|
1303
|
+
if (typeof candidate === "function") {
|
|
1304
|
+
try {
|
|
1305
|
+
candidate = candidate();
|
|
1306
|
+
}
|
|
1307
|
+
catch (error) {
|
|
1308
|
+
return { problem: `its target thunk threw: ${errorMessage(error)}` };
|
|
1309
|
+
}
|
|
1310
|
+
if (!isEntity(candidate)) {
|
|
1311
|
+
return {
|
|
1312
|
+
problem: "its target thunk must return an entity; is the entity defined and exported?",
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
if (typeof candidate === "string") {
|
|
1317
|
+
return scope.entitiesByName.has(candidate)
|
|
1318
|
+
? { name: candidate }
|
|
1319
|
+
: { problem: `it points at "${candidate}", which is not an entity of this ontology.` };
|
|
1320
|
+
}
|
|
1321
|
+
if (isEntity(candidate)) {
|
|
1322
|
+
const problem = `it points at ${candidate.name}, which is not part of the ontology; ` +
|
|
1323
|
+
"add it to defineOntology({ entities }).";
|
|
1324
|
+
return scope.definitions.has(candidate.definition) ? { name: candidate.name } : { problem };
|
|
1325
|
+
}
|
|
1326
|
+
return { problem: "it must point at an entity, a thunk returning one, or an entity name." };
|
|
1327
|
+
}
|
|
1328
|
+
function resolveRelationships(candidate, scope) {
|
|
1329
|
+
const { report } = scope;
|
|
1330
|
+
const relationships = [];
|
|
1331
|
+
for (const [name, relationship] of Object.entries(candidate.definition.relationships ?? {})) {
|
|
1332
|
+
const path = `${candidate.name}.relationships.${name}`;
|
|
1333
|
+
checkMemberName("relationship", path, name, report);
|
|
1334
|
+
if (!isRelationship(relationship)) {
|
|
1335
|
+
report("invalid-relationship", path, "is not a relationship; declare it with rel.one or rel.many.");
|
|
1336
|
+
continue;
|
|
1337
|
+
}
|
|
1338
|
+
const target = resolveTarget(relationship.target, scope);
|
|
1339
|
+
if ("problem" in target) {
|
|
1340
|
+
report("unknown-relationship-target", path, target.problem);
|
|
1341
|
+
}
|
|
1342
|
+
checkRelationship(path, relationship, report);
|
|
1343
|
+
relationships.push({ name, relationship, target: "name" in target ? target.name : undefined });
|
|
1344
|
+
}
|
|
1345
|
+
return relationships;
|
|
1346
|
+
}
|
|
1347
|
+
function checkRelationship(path, relationship, report) {
|
|
1348
|
+
if (isBlank(relationship.description)) {
|
|
1349
|
+
report("missing-description", path, "every relationship needs a description.");
|
|
1350
|
+
}
|
|
1351
|
+
checkMemberName("inverse", `${path}.inverse`, relationship.inverse, report);
|
|
1352
|
+
if (relationship.phrase !== undefined &&
|
|
1353
|
+
(relationship.phrase.length !== 2 || relationship.phrase.some((part) => isBlank(part)))) {
|
|
1354
|
+
report("invalid-phrase", path, "phrase names both directions: [forward, inverse].");
|
|
1355
|
+
}
|
|
1356
|
+
// checkWarehouseSource refuses a delete rule or optionality on a relationship matched by value.
|
|
1357
|
+
if (relationship.cardinality === "many" || relationship.on !== undefined) {
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
// Undeclared means restrict.
|
|
1361
|
+
const onDelete = relationship.onDelete ?? "restrict";
|
|
1362
|
+
if (!ON_DELETE.includes(onDelete)) {
|
|
1363
|
+
report("invalid-on-delete", path, `onDelete is one of ${ON_DELETE.join(", ")}.`);
|
|
1364
|
+
}
|
|
1365
|
+
else if (onDelete === "setNull" && !relationship.optional) {
|
|
1366
|
+
report("invalid-on-delete", path, 'onDelete "setNull" needs an optional relationship; use "cascade" or "restrict".');
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
/**
|
|
1370
|
+
* A relationship with a warehouse-backed end is matched by value, through `on`, and has no
|
|
1371
|
+
* `<name>_id` key. Its declaration may still lack `on`, which checkWarehouseSource reports.
|
|
1372
|
+
*/
|
|
1373
|
+
function isValueMatchedDeclaration(input) {
|
|
1374
|
+
const { owner, relationship, target } = input;
|
|
1375
|
+
return (relationship.cardinality === "one" &&
|
|
1376
|
+
(relationship.on !== undefined ||
|
|
1377
|
+
owner.definition.source !== undefined ||
|
|
1378
|
+
target?.definition.source !== undefined));
|
|
1379
|
+
}
|
|
1380
|
+
function resolveIndexes(candidate, properties, relationships, scope) {
|
|
1381
|
+
const valueMatched = new Set(relationships
|
|
1382
|
+
.filter(({ relationship, target }) => isValueMatchedDeclaration({
|
|
1383
|
+
owner: candidate,
|
|
1384
|
+
relationship,
|
|
1385
|
+
target: target === undefined ? undefined : scope.entitiesByName.get(target),
|
|
1386
|
+
}))
|
|
1387
|
+
.map((relationship) => relationship.name));
|
|
1388
|
+
const fields = new Set([
|
|
1389
|
+
...SYSTEM_PROPERTIES,
|
|
1390
|
+
...properties.map((property) => property.name),
|
|
1391
|
+
...relationships
|
|
1392
|
+
.filter((relationship) => relationship.relationship.cardinality === "one")
|
|
1393
|
+
.filter((relationship) => !valueMatched.has(relationship.name))
|
|
1394
|
+
.map((relationship) => relationship.name),
|
|
1395
|
+
]);
|
|
1396
|
+
const indexes = candidate.definition.indexes ?? [];
|
|
1397
|
+
return indexes.map((index, position) => {
|
|
1398
|
+
const path = `${candidate.name}.indexes[${position}]`;
|
|
1399
|
+
const resolved = Array.isArray(index)
|
|
1400
|
+
? { properties: index, unique: false }
|
|
1401
|
+
: {
|
|
1402
|
+
properties: index.properties ?? [],
|
|
1403
|
+
unique: index.unique === true,
|
|
1404
|
+
};
|
|
1405
|
+
if (resolved.properties.length === 0) {
|
|
1406
|
+
scope.report("invalid-index", path, "an index covers at least one field.");
|
|
1407
|
+
}
|
|
1408
|
+
for (const field of resolved.properties) {
|
|
1409
|
+
if (valueMatched.has(field)) {
|
|
1410
|
+
scope.report("invalid-index", path, `"${field}" is matched by value and has no key column. Index the property named in on instead.`);
|
|
1411
|
+
}
|
|
1412
|
+
else if (!fields.has(field)) {
|
|
1413
|
+
scope.report("invalid-index", path, `"${field}" is not a property, system property or rel.one relationship of ${candidate.name}.`);
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
return resolved;
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
/** An entity whose `.actions(…)` was never called: `actions` is still the builder. */
|
|
1420
|
+
function isBuiltInUser(candidate, scope) {
|
|
1421
|
+
return scope.builtIns.has(candidate.definition) && candidate.name === User.name;
|
|
1422
|
+
}
|
|
1423
|
+
function isDraft(candidate) {
|
|
1424
|
+
return typeof candidate.actions === "function";
|
|
1425
|
+
}
|
|
1426
|
+
function actionEntries(candidate, report) {
|
|
1427
|
+
const actions = candidate.actions;
|
|
1428
|
+
const path = `${candidate.name}.actions`;
|
|
1429
|
+
if ("actions" in candidate.definition) {
|
|
1430
|
+
report("invalid-actions", path, "declare actions with entity(…).actions((self) => ({ … })), not in the definition.");
|
|
1431
|
+
}
|
|
1432
|
+
if (isDraft(candidate)) {
|
|
1433
|
+
return [];
|
|
1434
|
+
}
|
|
1435
|
+
if (!isPlainObject(actions)) {
|
|
1436
|
+
report("invalid-actions", path, ".actions((self) => ({ … })) must return an object of actions.");
|
|
1437
|
+
return [];
|
|
1438
|
+
}
|
|
1439
|
+
return Object.entries(actions);
|
|
1440
|
+
}
|
|
1441
|
+
function resolveActions(candidate, scope) {
|
|
1442
|
+
const { report } = scope;
|
|
1443
|
+
const actions = [];
|
|
1444
|
+
for (const [name, action] of actionEntries(candidate, report)) {
|
|
1445
|
+
const path = `${candidate.name}.actions.${name}`;
|
|
1446
|
+
if (!SNAKE_CASE_NAME.test(name)) {
|
|
1447
|
+
report("invalid-name", path, "action names are lowercase snake_case, for example change_status.");
|
|
1448
|
+
}
|
|
1449
|
+
else if (GENERATED_ACTIONS.includes(name) ||
|
|
1450
|
+
GENERATED_UPSERT.test(name)) {
|
|
1451
|
+
report("reserved-name", path, `${name} is generated for every entity; choose another name.`);
|
|
1452
|
+
}
|
|
1453
|
+
else if (isBuiltInUser(candidate, scope) &&
|
|
1454
|
+
USER_MEMBER_VERBS.includes(name)) {
|
|
1455
|
+
report("reserved-name", path, `${name} is generated for the built-in user; choose another name, and call ` +
|
|
1456
|
+
"the User handle's member writes from its handler.");
|
|
1457
|
+
}
|
|
1458
|
+
if (!isAction(action)) {
|
|
1459
|
+
report("invalid-action", path, "is not an action; declare it with action({ … }).");
|
|
1460
|
+
continue;
|
|
1461
|
+
}
|
|
1462
|
+
if (isBlank(action.description)) {
|
|
1463
|
+
report("missing-description", path, "every action needs a description.");
|
|
1464
|
+
}
|
|
1465
|
+
if ("allow" in action) {
|
|
1466
|
+
report("removed-allow", `${path}.allow`, REMOVED_ALLOW);
|
|
1467
|
+
}
|
|
1468
|
+
if (action.retryOnDeadlock !== undefined &&
|
|
1469
|
+
(typeof action.retryOnDeadlock !== "boolean" ||
|
|
1470
|
+
(action.retryOnDeadlock && !action.transactional))) {
|
|
1471
|
+
report("invalid-action", `${path}.retryOnDeadlock`, "retryOnDeadlock is a boolean and requires a transactional handler when true.");
|
|
1472
|
+
}
|
|
1473
|
+
if (action.files !== undefined) {
|
|
1474
|
+
if (!Array.isArray(action.files) ||
|
|
1475
|
+
action.prepare === undefined ||
|
|
1476
|
+
(action.files.length > 0 && (!action.transactional || action.hints?.readOnly === true))) {
|
|
1477
|
+
report("invalid-action", `${path}.files`, "file staging requires prepare and a transactional handler.");
|
|
1478
|
+
}
|
|
1479
|
+
else {
|
|
1480
|
+
const targets = new Set();
|
|
1481
|
+
for (const target of action.files) {
|
|
1482
|
+
const entity = scope.entitiesByName.get(target?.entity);
|
|
1483
|
+
const definition = entity?.definition;
|
|
1484
|
+
const property = definition?.properties[target?.property];
|
|
1485
|
+
const identity = `${target?.entity}.${target?.property}`;
|
|
1486
|
+
if (!entity ||
|
|
1487
|
+
definition?.source ||
|
|
1488
|
+
propertyDefinition(property)?.type !== "blob" ||
|
|
1489
|
+
targets.has(identity)) {
|
|
1490
|
+
report("invalid-action", `${path}.files`, "file targets must name distinct stored blob properties.");
|
|
1491
|
+
}
|
|
1492
|
+
targets.add(identity);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
actions.push({
|
|
1497
|
+
name,
|
|
1498
|
+
action,
|
|
1499
|
+
permission: resolvePermission(path, permissionOf(candidate.name, name), action, scope),
|
|
1500
|
+
input: resolveInput(path, action.input, scope),
|
|
1501
|
+
connections: resolveActionConnections(path, action, scope, scope.builtIns.has(candidate.definition)),
|
|
1502
|
+
hints: resolveHints(path, action, report),
|
|
1503
|
+
writes: resolveActionWrites(path, action, scope),
|
|
1504
|
+
});
|
|
1505
|
+
}
|
|
1506
|
+
return actions;
|
|
1507
|
+
}
|
|
1508
|
+
/**
|
|
1509
|
+
* The entity an action's `writes` names. Handles are matched by their stable id: the handles a
|
|
1510
|
+
* handler closes over are the authored ones, which the analysis has converted to wire names.
|
|
1511
|
+
*/
|
|
1512
|
+
function resolveWriteTarget(target, scope) {
|
|
1513
|
+
let candidate = target;
|
|
1514
|
+
if (typeof candidate === "function" && !isEntity(candidate)) {
|
|
1515
|
+
try {
|
|
1516
|
+
candidate = candidate();
|
|
1517
|
+
}
|
|
1518
|
+
catch (error) {
|
|
1519
|
+
return { problem: `its thunk threw: ${errorMessage(error)}` };
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
if (typeof candidate === "string") {
|
|
1523
|
+
const name = nameToWire(candidate);
|
|
1524
|
+
return scope.entitiesByName.has(name)
|
|
1525
|
+
? { name }
|
|
1526
|
+
: { problem: `"${candidate}" is not an entity of this ontology.` };
|
|
1527
|
+
}
|
|
1528
|
+
if (!isEntity(candidate)) {
|
|
1529
|
+
return { problem: "it must be an entity, a thunk returning one, or an entity name." };
|
|
1530
|
+
}
|
|
1531
|
+
const id = candidate.definition.id;
|
|
1532
|
+
for (const [name, entity] of scope.entitiesByName) {
|
|
1533
|
+
if (entity.definition.id === id) {
|
|
1534
|
+
return { name };
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
return {
|
|
1538
|
+
problem: `${candidate.name} is not part of the ontology; add it to defineOntology({ entities }).`,
|
|
1539
|
+
};
|
|
1540
|
+
}
|
|
1541
|
+
/**
|
|
1542
|
+
* The entities an action declares it writes, by wire name: stored entities of the ontology, each
|
|
1543
|
+
* once. `undefined` when it declares none, so the handler may write any entity.
|
|
1544
|
+
*/
|
|
1545
|
+
function resolveActionWrites(path, action, scope) {
|
|
1546
|
+
const declared = action.writes;
|
|
1547
|
+
if (declared === undefined) {
|
|
1548
|
+
return undefined;
|
|
1549
|
+
}
|
|
1550
|
+
if (!Array.isArray(declared)) {
|
|
1551
|
+
scope.report("invalid-action", `${path}.writes`, "writes lists the entities the handler writes, such as [self, Note].");
|
|
1552
|
+
return undefined;
|
|
1553
|
+
}
|
|
1554
|
+
const names = [];
|
|
1555
|
+
declared.forEach((target, index) => {
|
|
1556
|
+
const at = `${path}.writes[${index}]`;
|
|
1557
|
+
const resolved = resolveWriteTarget(target, scope);
|
|
1558
|
+
if ("problem" in resolved) {
|
|
1559
|
+
scope.report("invalid-action", at, `writes names an entity the handler writes, but ${resolved.problem}`);
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
const name = nameToWire(resolved.name);
|
|
1563
|
+
const entity = scope.entitiesByName.get(name);
|
|
1564
|
+
if (entity?.definition.source !== undefined) {
|
|
1565
|
+
scope.report("invalid-action", at, `${name} is warehouse-backed, so no action writes it; remove it from writes.`);
|
|
1566
|
+
}
|
|
1567
|
+
else if (names.includes(name)) {
|
|
1568
|
+
scope.report("invalid-action", at, `${name} is listed twice in writes.`);
|
|
1569
|
+
}
|
|
1570
|
+
else {
|
|
1571
|
+
names.push(name);
|
|
1572
|
+
}
|
|
1573
|
+
});
|
|
1574
|
+
return names;
|
|
1575
|
+
}
|
|
1576
|
+
const HINT_NAMES = [
|
|
1577
|
+
"readOnly",
|
|
1578
|
+
"connectionsReadOnly",
|
|
1579
|
+
"destructive",
|
|
1580
|
+
"irreversible",
|
|
1581
|
+
"idempotent",
|
|
1582
|
+
];
|
|
1583
|
+
/** Validate safety labels before publishing them to callers. */
|
|
1584
|
+
function resolveHints(path, action, report) {
|
|
1585
|
+
const declared = action.hints;
|
|
1586
|
+
if (declared === undefined) {
|
|
1587
|
+
return undefined;
|
|
1588
|
+
}
|
|
1589
|
+
const at = `${path}.hints`;
|
|
1590
|
+
if (!isPlainObject(declared)) {
|
|
1591
|
+
report("invalid-action", at, "hints is an object such as { destructive: false }.");
|
|
1592
|
+
return undefined;
|
|
1593
|
+
}
|
|
1594
|
+
const hints = {};
|
|
1595
|
+
for (const [key, value] of Object.entries(declared)) {
|
|
1596
|
+
if (!HINT_NAMES.includes(key)) {
|
|
1597
|
+
report("invalid-action", `${at}.${key}`, `is not a hint; use ${HINT_NAMES.join(" or ")}.`);
|
|
1598
|
+
}
|
|
1599
|
+
else if (typeof value !== "boolean") {
|
|
1600
|
+
report("invalid-action", `${at}.${key}`, "is true or false.");
|
|
1601
|
+
}
|
|
1602
|
+
else {
|
|
1603
|
+
hints[key] = value;
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
if (hints.readOnly === true) {
|
|
1607
|
+
if (action.prepare === undefined || action.transactional) {
|
|
1608
|
+
report("invalid-action", `${at}.readOnly`, "readOnly requires prepare without a handler.");
|
|
1609
|
+
}
|
|
1610
|
+
if (hints.destructive === true || hints.irreversible === true) {
|
|
1611
|
+
report("invalid-action", `${at}.readOnly`, "A read-only action cannot be destructive or irreversible.");
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
return hints;
|
|
1615
|
+
}
|
|
1616
|
+
/** The action's own `permission`, which names an entity and a verb without wildcards. */
|
|
1617
|
+
function resolvePermission(path, fallback, action, scope) {
|
|
1618
|
+
const declared = action.permission;
|
|
1619
|
+
if (declared === undefined) {
|
|
1620
|
+
return fallback;
|
|
1621
|
+
}
|
|
1622
|
+
const parsed = parsePermission(declared);
|
|
1623
|
+
if (parsed === undefined || parsed.entity === WILDCARD || parsed.verb === WILDCARD) {
|
|
1624
|
+
scope.report("invalid-permission", `${path}.permission`, `${JSON.stringify(declared)} is not a permission an action can require; ` +
|
|
1625
|
+
`write <entity>:<verb> without wildcards, such as ${fallback}.`);
|
|
1626
|
+
return fallback;
|
|
1627
|
+
}
|
|
1628
|
+
if (!scope.entitiesByName.has(parsed.entity)) {
|
|
1629
|
+
scope.report("unknown-permission", `${path}.permission`, `${parsed.entity} is not an entity of this ontology.`);
|
|
1630
|
+
}
|
|
1631
|
+
return permissionOf(parsed.entity, parsed.verb);
|
|
1632
|
+
}
|
|
1633
|
+
function resolveInput(path, input, scope) {
|
|
1634
|
+
const inputPath = `${path}.input`;
|
|
1635
|
+
if (!Schema.isSchema(input) || Schema.toEncoded(input).ast._tag !== "Objects") {
|
|
1636
|
+
scope.report("invalid-action-input", inputPath, "must be a Schema.Struct of the action's inputs.");
|
|
1637
|
+
return undefined;
|
|
1638
|
+
}
|
|
1639
|
+
if ("fields" in input) {
|
|
1640
|
+
checkInputFields(inputPath, input.fields, scope);
|
|
1641
|
+
}
|
|
1642
|
+
try {
|
|
1643
|
+
const document = Schema.toJsonSchemaDocument(input, {
|
|
1644
|
+
includeAnnotationKey: (key) => key === REFERENCE_ANNOTATION || key === BLOB_UPLOAD_ANNOTATION,
|
|
1645
|
+
onExcessProperty: "error",
|
|
1646
|
+
});
|
|
1647
|
+
const schema = document.schema;
|
|
1648
|
+
checkEncodedInputNames(schema, inputPath, scope, !("fields" in input));
|
|
1649
|
+
for (const definition of Object.values(document.definitions)) {
|
|
1650
|
+
checkEncodedInputNames(definition, inputPath, scope, !("fields" in input));
|
|
1651
|
+
}
|
|
1652
|
+
return Object.keys(document.definitions).length === 0
|
|
1653
|
+
? schema
|
|
1654
|
+
: { ...schema, $defs: document.definitions };
|
|
1655
|
+
}
|
|
1656
|
+
catch (error) {
|
|
1657
|
+
scope.report("invalid-action-input", inputPath, `has no JSON Schema form: ${errorMessage(error)}`);
|
|
1658
|
+
return undefined;
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
/** Check named input fields on the wire side; codecs may decode them to camelCase handler values. */
|
|
1662
|
+
function checkEncodedInputNames(schema, path, scope, checkReferences) {
|
|
1663
|
+
const { report } = scope;
|
|
1664
|
+
const reference = schema[REFERENCE_ANNOTATION];
|
|
1665
|
+
if (checkReferences && typeof reference === "string" && !scope.entitiesByName.has(reference)) {
|
|
1666
|
+
report("unknown-reference", path, `references "${reference}", which is not an entity of this ontology.`);
|
|
1667
|
+
}
|
|
1668
|
+
const properties = schema.properties;
|
|
1669
|
+
if (properties !== null && typeof properties === "object" && !Array.isArray(properties)) {
|
|
1670
|
+
for (const [name, child] of Object.entries(properties)) {
|
|
1671
|
+
if (name === "_segmentstream" || name === "_confirmation") {
|
|
1672
|
+
report("reserved-name", `${path}.${name}`, `${name} is reserved for runtime call metadata and confirmation presentation.`);
|
|
1673
|
+
}
|
|
1674
|
+
if (!SNAKE_CASE_NAME.test(name)) {
|
|
1675
|
+
report("invalid-name", `${path}.${name}`, "action input names are lowercase snake_case.");
|
|
1676
|
+
}
|
|
1677
|
+
if (child !== null && typeof child === "object" && !Array.isArray(child)) {
|
|
1678
|
+
checkEncodedInputNames(child, `${path}.${name}`, scope, checkReferences);
|
|
1679
|
+
}
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
for (const key of ["items", "additionalProperties", "anyOf", "oneOf", "allOf"]) {
|
|
1683
|
+
const children = schema[key];
|
|
1684
|
+
for (const child of Array.isArray(children) ? children : [children]) {
|
|
1685
|
+
if (child !== null && typeof child === "object" && !Array.isArray(child)) {
|
|
1686
|
+
checkEncodedInputNames(child, path, scope, checkReferences);
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
function checkInputFields(path, fields, scope) {
|
|
1692
|
+
for (const [name, field] of Object.entries(fields)) {
|
|
1693
|
+
const fieldPath = `${path}.${name}`;
|
|
1694
|
+
const reference = referenceTarget(field);
|
|
1695
|
+
if (reference !== undefined && !scope.entitiesByName.has(nameToWire(reference))) {
|
|
1696
|
+
scope.report("unknown-reference", fieldPath, `references "${reference}", which is not an entity of this ontology.`);
|
|
1697
|
+
}
|
|
1698
|
+
if (scope.entityProperties.has(field)) {
|
|
1699
|
+
continue;
|
|
1700
|
+
}
|
|
1701
|
+
const definition = propertyDefinition(field);
|
|
1702
|
+
if (definition === undefined) {
|
|
1703
|
+
continue;
|
|
1704
|
+
}
|
|
1705
|
+
checkPropertyOptions(fieldPath, field, scope.report);
|
|
1706
|
+
if (definition.type === "select") {
|
|
1707
|
+
checkSelectOptions(fieldPath, definition, scope.report);
|
|
1708
|
+
}
|
|
1709
|
+
if (definition.managed) {
|
|
1710
|
+
scope.report("managed-without-effect", fieldPath, "managed only affects entity properties; this input field is never written by update.", "warning");
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
function checkEntityIdentities(entities, report) {
|
|
1715
|
+
const names = new Map();
|
|
1716
|
+
const ids = new Map();
|
|
1717
|
+
for (const candidate of entities) {
|
|
1718
|
+
const { definition } = candidate.entity;
|
|
1719
|
+
const existingName = names.get(candidate.name);
|
|
1720
|
+
const existingId = ids.get(definition.id);
|
|
1721
|
+
if (existingName !== undefined) {
|
|
1722
|
+
const builtIn = existingName.builtIn
|
|
1723
|
+
? ` (the built-in ${existingName.entity.definition.label})`
|
|
1724
|
+
: "";
|
|
1725
|
+
report("duplicate-name", candidate.name, `another entity is named ${candidate.name}${builtIn}.`);
|
|
1726
|
+
}
|
|
1727
|
+
else {
|
|
1728
|
+
names.set(candidate.name, candidate);
|
|
1729
|
+
}
|
|
1730
|
+
if (!ENTITY_ID.test(definition.id ?? "")) {
|
|
1731
|
+
report("invalid-entity-id", candidate.name, "entity ids look like ent_01J9Q4M8ZK and never change.");
|
|
1732
|
+
}
|
|
1733
|
+
else if (existingId !== undefined) {
|
|
1734
|
+
report("duplicate-entity-id", candidate.name, `${existingId.name} already has the id ${definition.id}.`);
|
|
1735
|
+
}
|
|
1736
|
+
else {
|
|
1737
|
+
ids.set(definition.id, candidate);
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
const currentNames = new Set(entities.map((candidate) => candidate.name));
|
|
1741
|
+
for (const candidate of entities) {
|
|
1742
|
+
const { renamedFrom } = candidate.entity.definition;
|
|
1743
|
+
if (renamedFrom !== undefined) {
|
|
1744
|
+
checkRenamedFrom(candidate.name, candidate.name, renamedFrom, currentNames, SNAKE_CASE_NAME, report);
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
/**
|
|
1749
|
+
* Properties, relationships, `<relationship>_id` fields and the inverses that land on an entity share
|
|
1750
|
+
* one namespace: they are the record's fields and the API's traversal paths.
|
|
1751
|
+
*/
|
|
1752
|
+
function checkMemberNames(entities, report) {
|
|
1753
|
+
const entitiesByName = new Map();
|
|
1754
|
+
for (const candidate of [...entities].reverse()) {
|
|
1755
|
+
entitiesByName.set(candidate.name, candidate);
|
|
1756
|
+
}
|
|
1757
|
+
const members = new Map(entities.map((candidate) => [candidate, ownMembers(candidate, entitiesByName)]));
|
|
1758
|
+
for (const candidate of entities) {
|
|
1759
|
+
for (const { name, relationship, target } of candidate.relationships) {
|
|
1760
|
+
const landing = target === undefined ? undefined : entitiesByName.get(target);
|
|
1761
|
+
if (landing === undefined) {
|
|
1762
|
+
continue;
|
|
1763
|
+
}
|
|
1764
|
+
const path = `${candidate.name}.relationships.${name}.inverse`;
|
|
1765
|
+
members.get(landing)?.push({ name: relationship.inverse, path, inverse: true });
|
|
1766
|
+
if (Object.hasOwn(landing.entity.definition.metrics ?? {}, relationship.inverse)) {
|
|
1767
|
+
report("inverse-collision", path, `${relationship.inverse} is already a metric of ${landing.name}.`);
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
for (const [candidate, list] of members) {
|
|
1772
|
+
const seen = new Map();
|
|
1773
|
+
for (const current of list) {
|
|
1774
|
+
const previous = seen.get(current.name);
|
|
1775
|
+
if (previous === undefined) {
|
|
1776
|
+
seen.set(current.name, current);
|
|
1777
|
+
continue;
|
|
1778
|
+
}
|
|
1779
|
+
const code = previous.inverse || current.inverse ? "inverse-collision" : "duplicate-name";
|
|
1780
|
+
report(code, current.path, `${current.name} is already a field of ${candidate.name} (${previous.path}).`);
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
function ownMembers(candidate, entitiesByName) {
|
|
1785
|
+
const properties = candidate.properties.map((property) => ({
|
|
1786
|
+
name: property.name,
|
|
1787
|
+
path: `${candidate.name}.properties.${property.name}`,
|
|
1788
|
+
inverse: false,
|
|
1789
|
+
}));
|
|
1790
|
+
const relationships = candidate.relationships.flatMap(({ name, relationship, target }) => {
|
|
1791
|
+
const path = `${candidate.name}.relationships.${name}`;
|
|
1792
|
+
const own = { name, path, inverse: false };
|
|
1793
|
+
const landing = target === undefined ? undefined : entitiesByName.get(target);
|
|
1794
|
+
// Only a stored → stored rel.one has a `<name>_id` key.
|
|
1795
|
+
const keyed = relationship.cardinality === "one" &&
|
|
1796
|
+
!isValueMatchedDeclaration({
|
|
1797
|
+
owner: candidate.entity,
|
|
1798
|
+
relationship,
|
|
1799
|
+
target: landing?.entity,
|
|
1800
|
+
});
|
|
1801
|
+
return keyed ? [own, { ...own, name: `${name}_id` }] : [own];
|
|
1802
|
+
});
|
|
1803
|
+
return [...properties, ...relationships];
|
|
1804
|
+
}
|
|
1805
|
+
/**
|
|
1806
|
+
* The verbs each entity has: `read`, its generated actions, and what its actions require. The
|
|
1807
|
+
* built-in user also has its member management verbs, which roles may grant.
|
|
1808
|
+
*/
|
|
1809
|
+
function knownVerbs(entities) {
|
|
1810
|
+
const verbs = new Map();
|
|
1811
|
+
for (const candidate of entities) {
|
|
1812
|
+
const own = verbs.get(candidate.name) ?? new Set();
|
|
1813
|
+
for (const permission of Object.values(candidate.permissions)) {
|
|
1814
|
+
const parsed = parsePermission(permission);
|
|
1815
|
+
if (parsed !== undefined) {
|
|
1816
|
+
own.add(parsed.verb);
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
if (candidate.builtIn && candidate.name === User.name) {
|
|
1820
|
+
for (const verb of USER_MEMBER_VERBS) {
|
|
1821
|
+
own.add(verb);
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
verbs.set(candidate.name, own);
|
|
1825
|
+
}
|
|
1826
|
+
for (const candidate of entities) {
|
|
1827
|
+
for (const { permission } of candidate.actions) {
|
|
1828
|
+
const parsed = parsePermission(permission);
|
|
1829
|
+
if (parsed !== undefined) {
|
|
1830
|
+
verbs.get(parsed.entity)?.add(parsed.verb);
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
return verbs;
|
|
1835
|
+
}
|
|
1836
|
+
/** Why a role's permission matches nothing in the ontology, or `undefined` when it matches. */
|
|
1837
|
+
function unknownPermission(value, { entity, verb }, verbs) {
|
|
1838
|
+
if (entity === WILDCARD) {
|
|
1839
|
+
// A wildcard entity does not reach what only a named permission covers.
|
|
1840
|
+
const matched = verb === WILDCARD ||
|
|
1841
|
+
[...verbs].some(([name, own]) => own.has(verb) && wildcardEntityCovers(name, verb));
|
|
1842
|
+
return matched ? undefined : `${value} matches nothing: no entity has ${verb}.`;
|
|
1843
|
+
}
|
|
1844
|
+
const own = verbs.get(entity);
|
|
1845
|
+
if (own === undefined) {
|
|
1846
|
+
return `${value} names ${entity}, which is not an entity of this ontology.`;
|
|
1847
|
+
}
|
|
1848
|
+
if (verb === WILDCARD || own.has(verb)) {
|
|
1849
|
+
return undefined;
|
|
1850
|
+
}
|
|
1851
|
+
if (verb.startsWith(GENERATED_UPSERT_PREFIX)) {
|
|
1852
|
+
const [create, update] = generatedActionPermissions(entity, verb);
|
|
1853
|
+
return `${value} is not a permission: ${verb} requires ${create} and ${update}.`;
|
|
1854
|
+
}
|
|
1855
|
+
return `${value} is not a permission of this ontology; ${entity} has ${[...own].join(", ")}.`;
|
|
1856
|
+
}
|
|
1857
|
+
function checkRolePermissions(candidate, verbs, workflows, report) {
|
|
1858
|
+
const builtIn = candidate.name === MEMBER_ROLE;
|
|
1859
|
+
const path = builtIn ? "member.permissions" : `roles.${candidate.name}.permissions`;
|
|
1860
|
+
const permissions = candidate.permissions;
|
|
1861
|
+
if (!Array.isArray(permissions)) {
|
|
1862
|
+
report("missing-permissions", path, "list what the role may do as <entity>:<verb> permissions, such as " +
|
|
1863
|
+
'permissions: ["lead:read"].');
|
|
1864
|
+
return;
|
|
1865
|
+
}
|
|
1866
|
+
// An empty member list is a choice: members then hold only the roles they are given.
|
|
1867
|
+
if (permissions.length === 0 && !builtIn) {
|
|
1868
|
+
report("missing-permissions", path, "a role without permissions can do nothing; list at least one, such as lead:read.");
|
|
1869
|
+
}
|
|
1870
|
+
permissions.forEach((value, index) => {
|
|
1871
|
+
const at = `${path}[${index}]`;
|
|
1872
|
+
const workflow = parseWorkflowPermission(value);
|
|
1873
|
+
if (workflow !== undefined) {
|
|
1874
|
+
if (workflow.workflow !== WILDCARD && !workflows.has(workflow.workflow)) {
|
|
1875
|
+
report("unknown-permission", at, `${String(value)} names ${workflow.workflow}, which is not a workflow of this ontology.`);
|
|
1876
|
+
}
|
|
1877
|
+
return;
|
|
1878
|
+
}
|
|
1879
|
+
const parsed = parsePermission(value);
|
|
1880
|
+
if (parsed === undefined) {
|
|
1881
|
+
report("invalid-permission", at, `${JSON.stringify(value)} is not a permission; write <entity>:<verb>, such as lead:read, ` +
|
|
1882
|
+
"lead:* or *:read, or workflow:<workflow>:start or workflow:<workflow>:read.");
|
|
1883
|
+
return;
|
|
1884
|
+
}
|
|
1885
|
+
if (parsed.entity === WILDCARD && parsed.verb === WILDCARD) {
|
|
1886
|
+
report("wildcard-permission", at, "*:* grants every entity and action, including those added later, except agent_chat, " +
|
|
1887
|
+
"app_feedback, app_feedback_file, user_identity and " +
|
|
1888
|
+
`administering users (${USER_MEMBER_VERBS.map((verb) => `user:${verb}`).join(", ")} ` +
|
|
1889
|
+
"and user's authored actions), which only user:<verb> or user:* grants.", "warning");
|
|
1890
|
+
return;
|
|
1891
|
+
}
|
|
1892
|
+
const problem = unknownPermission(`${parsed.entity}:${parsed.verb}`, parsed, verbs);
|
|
1893
|
+
if (problem !== undefined) {
|
|
1894
|
+
report("unknown-permission", at, problem);
|
|
1895
|
+
}
|
|
1896
|
+
});
|
|
1897
|
+
}
|
|
1898
|
+
/**
|
|
1899
|
+
* Under the open policy anyone who signs in holds `member`. Warehouse-backed entities hold company
|
|
1900
|
+
* figures, such as revenue or stock, that metric queries read with `<entity>:read`; a wildcard of
|
|
1901
|
+
* member's (`*:read`, the default, or `*:*`) must not hand them to strangers. Granting a
|
|
1902
|
+
* warehouse-backed entity by name, `<entity>:read` or `<entity>:*`, is a deliberate choice.
|
|
1903
|
+
*/
|
|
1904
|
+
function checkOpenWarehouseReads(input) {
|
|
1905
|
+
const { entities, roles, signIn, report } = input;
|
|
1906
|
+
const warehouseBacked = entities
|
|
1907
|
+
.filter((candidate) => candidate.entity.definition.source !== undefined)
|
|
1908
|
+
.map((candidate) => candidate.name);
|
|
1909
|
+
const memberPermissions = roles.find((candidate) => candidate.name === MEMBER_ROLE)?.permissions;
|
|
1910
|
+
if (signIn.policy !== "open" ||
|
|
1911
|
+
warehouseBacked.length === 0 ||
|
|
1912
|
+
!Array.isArray(memberPermissions)) {
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
const wildcards = memberPermissions.filter((permission) => {
|
|
1916
|
+
const parsed = parsePermission(permission);
|
|
1917
|
+
return (parsed !== undefined &&
|
|
1918
|
+
parsed.entity === WILDCARD &&
|
|
1919
|
+
(parsed.verb === WILDCARD || parsed.verb === READ_VERB));
|
|
1920
|
+
});
|
|
1921
|
+
if (wildcards.length === 0) {
|
|
1922
|
+
return;
|
|
1923
|
+
}
|
|
1924
|
+
report("invalid-sign-in", "signIn.policy", `signIn policy open lets anyone who signs in hold member; member's ${wildcards.join(", ")} ` +
|
|
1925
|
+
`${wildcards.length === 1 ? "reaches" : "reach"} warehouse-backed entities ` +
|
|
1926
|
+
`(${warehouseBacked.join(", ")}). Grant them to member by name, such as ` +
|
|
1927
|
+
`${warehouseBacked[0]}:read, or use the invited policy.`);
|
|
1928
|
+
}
|
|
1929
|
+
/** Public channels admit strangers with explicitly reviewed baseline grants. */
|
|
1930
|
+
function checkOpenChannels(input) {
|
|
1931
|
+
const { config, entities, roles, signIn, report } = input;
|
|
1932
|
+
if (!Object.values(signIn.channels ?? {}).includes("open")) {
|
|
1933
|
+
return;
|
|
1934
|
+
}
|
|
1935
|
+
const hint = "move staff reads into a named role";
|
|
1936
|
+
if (config.member === undefined) {
|
|
1937
|
+
report("invalid-sign-in", "member", `Open channels require explicit member permissions; ${hint}.`);
|
|
1938
|
+
return;
|
|
1939
|
+
}
|
|
1940
|
+
const permissions = roles.find((candidate) => candidate.name === MEMBER_ROLE)?.permissions;
|
|
1941
|
+
if (!Array.isArray(permissions)) {
|
|
1942
|
+
report("invalid-sign-in", "member.permissions", "Open channels require an explicit permissions array.");
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1945
|
+
for (const permission of permissions) {
|
|
1946
|
+
const parsed = parsePermission(permission);
|
|
1947
|
+
const entity = entities.find((candidate) => candidate.name === parsed?.entity);
|
|
1948
|
+
const generatedWrite = entity !== undefined &&
|
|
1949
|
+
[
|
|
1950
|
+
entity.permissions.create,
|
|
1951
|
+
entity.permissions.update,
|
|
1952
|
+
entity.permissions.delete,
|
|
1953
|
+
entity.permissions.link,
|
|
1954
|
+
entity.permissions.unlink,
|
|
1955
|
+
].some((required) => required !== undefined && isAllowed(required, { permissions: [permission] }));
|
|
1956
|
+
if (parsed?.entity === WILDCARD || generatedWrite) {
|
|
1957
|
+
report("invalid-sign-in", "member.permissions", `Open channels cannot grant member ${permission}; ${hint}. Use ownership-checked app actions.`);
|
|
1958
|
+
}
|
|
1959
|
+
else if (entity !== undefined &&
|
|
1960
|
+
isAllowed(entity.permissions.read, { permissions: [permission] })) {
|
|
1961
|
+
report("invalid-sign-in", "member.permissions", `${permission} permits shared reads; review public catalogs and ${hint}.`, "warning");
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
/** The hook has only member authority and cannot enter the external preparation phase. */
|
|
1966
|
+
function checkNewUserAction(input) {
|
|
1967
|
+
const { entities, roles, signIn, report } = input;
|
|
1968
|
+
if (signIn.onNewUser === undefined) {
|
|
1969
|
+
return;
|
|
1970
|
+
}
|
|
1971
|
+
const parsed = parsePermission(signIn.onNewUser);
|
|
1972
|
+
const found = entities
|
|
1973
|
+
.find((entity) => entity.name === parsed?.entity)
|
|
1974
|
+
?.actions.find((candidate) => candidate.name === parsed?.verb);
|
|
1975
|
+
const fail = (message) => report("invalid-sign-in", "signIn.onNewUser", message);
|
|
1976
|
+
if (found === undefined) {
|
|
1977
|
+
fail("onNewUser must name an existing authored action.");
|
|
1978
|
+
return;
|
|
1979
|
+
}
|
|
1980
|
+
if (Result.isFailure(Schema.decodeUnknownResult(found.action.input)({}))) {
|
|
1981
|
+
fail("onNewUser cannot have required input; it is called with {}.");
|
|
1982
|
+
}
|
|
1983
|
+
if (!isAllowed(found.permission, { permissions: roles[0]?.permissions ?? [] })) {
|
|
1984
|
+
fail("onNewUser must be runnable by member.");
|
|
1985
|
+
}
|
|
1986
|
+
if (found.action.hints?.idempotent !== true) {
|
|
1987
|
+
fail("onNewUser must declare hints.idempotent: true.");
|
|
1988
|
+
}
|
|
1989
|
+
if (found.action.prepare !== undefined || found.action.connections.length > 0) {
|
|
1990
|
+
fail("onNewUser cannot prepare or use external connections.");
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
/** The member management verbs that act on members a page or an agent must first find. */
|
|
1994
|
+
const ROSTER_VERBS = ["invite", "grant_roles"];
|
|
1995
|
+
/**
|
|
1996
|
+
* A role that may invite members or grant roles must be able to read users, or it has no roster to
|
|
1997
|
+
* act on. A member holds `member` beside their roles, so member's permissions count for every role.
|
|
1998
|
+
*/
|
|
1999
|
+
function checkRosterReads(roles, report) {
|
|
2000
|
+
const permissionsOf = (candidate) => Array.isArray(candidate?.permissions) ? candidate.permissions : [];
|
|
2001
|
+
const memberPermissions = permissionsOf(roles.find((candidate) => candidate.name === MEMBER_ROLE));
|
|
2002
|
+
for (const candidate of roles) {
|
|
2003
|
+
const builtIn = candidate.name === MEMBER_ROLE;
|
|
2004
|
+
const permissions = builtIn
|
|
2005
|
+
? memberPermissions
|
|
2006
|
+
: [...permissionsOf(candidate), ...memberPermissions];
|
|
2007
|
+
const holder = { permissions };
|
|
2008
|
+
const writes = ROSTER_VERBS.map((verb) => permissionOf(User.name, verb)).filter((permission) => isAllowed(permission, holder));
|
|
2009
|
+
if (writes.length > 0 && !isAllowed(permissionOf(User.name, READ_VERB), holder)) {
|
|
2010
|
+
report("missing-permissions", builtIn ? "member.permissions" : `roles.${candidate.name}.permissions`, `${writes.join(" and ")} without user:read cannot find the members to act on; ` +
|
|
2011
|
+
"add user:read.");
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
/**
|
|
2016
|
+
* Every permission a role lists must match an entity and a verb of the ontology, and an authored
|
|
2017
|
+
* action that no role grants is a warning. Generated actions are not warned about: they stay closed
|
|
2018
|
+
* until a role grants them.
|
|
2019
|
+
*/
|
|
2020
|
+
function checkPermissions(entities, roles, workflows, report, authoredRoles = roles) {
|
|
2021
|
+
const verbs = knownVerbs(entities);
|
|
2022
|
+
const workflowNames = new Set(workflows.map((candidate) => candidate.name));
|
|
2023
|
+
for (const candidate of authoredRoles) {
|
|
2024
|
+
checkRolePermissions(candidate, verbs, workflowNames, report);
|
|
2025
|
+
}
|
|
2026
|
+
checkRosterReads(authoredRoles, report);
|
|
2027
|
+
checkScopes(entities, authoredRoles, verbs, report);
|
|
2028
|
+
const granting = roles.map((candidate) => ({
|
|
2029
|
+
name: candidate.name,
|
|
2030
|
+
permissions: Array.isArray(candidate.permissions) ? candidate.permissions : [],
|
|
2031
|
+
}));
|
|
2032
|
+
// Built-in actions are granted by the workspace's roles when it wants them.
|
|
2033
|
+
for (const candidate of entities.filter((entity) => !entity.builtIn)) {
|
|
2034
|
+
for (const { name, permission } of candidate.actions) {
|
|
2035
|
+
const parsed = parsePermission(permission);
|
|
2036
|
+
// An invalid name or permission is already an error; it needs no warning on top.
|
|
2037
|
+
const checkable = parsed !== undefined && verbs.has(parsed.entity);
|
|
2038
|
+
if (checkable && rolesGranting(granting, permission).length === 0) {
|
|
2039
|
+
report("unreachable-action", `${candidate.name}.actions.${name}`, `no role grants ${permission}, so nobody can run this action; ` +
|
|
2040
|
+
"add it to a role's permissions or to member's.", "warning");
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
}
|
|
2045
|
+
/** The id a `me` marker stands for while the entity's query schema validates a scope. */
|
|
2046
|
+
const SCOPE_PLACEHOLDER_ID = "00000000-0000-4000-8000-000000000000";
|
|
2047
|
+
const SCOPE_KEYS = ["permission", "where", "readsAll"];
|
|
2048
|
+
const FIELD_OPERATOR_NAMES = [
|
|
2049
|
+
"eq",
|
|
2050
|
+
"ne",
|
|
2051
|
+
"in",
|
|
2052
|
+
"is_null",
|
|
2053
|
+
"isNull",
|
|
2054
|
+
"contains",
|
|
2055
|
+
"starts_with",
|
|
2056
|
+
"startsWith",
|
|
2057
|
+
"gt",
|
|
2058
|
+
"gte",
|
|
2059
|
+
"lt",
|
|
2060
|
+
"lte",
|
|
2061
|
+
"between",
|
|
2062
|
+
];
|
|
2063
|
+
/** Whether a condition is a field filter: an object whose keys are all operators. */
|
|
2064
|
+
function isFieldOperators(value) {
|
|
2065
|
+
return (isPlainObject(value) &&
|
|
2066
|
+
Object.keys(value).length > 0 &&
|
|
2067
|
+
Object.keys(value).every((key) => FIELD_OPERATOR_NAMES.includes(key)));
|
|
2068
|
+
}
|
|
2069
|
+
/** `me.staffMember.division`, as the author wrote it. */
|
|
2070
|
+
function mePathText(hops) {
|
|
2071
|
+
return ["me", ...hops.map(toCamelCase)].join(".");
|
|
2072
|
+
}
|
|
2073
|
+
function scopeProblem(walk, code, path, message) {
|
|
2074
|
+
walk.problems += 1;
|
|
2075
|
+
walk.report(code, path, message);
|
|
2076
|
+
}
|
|
2077
|
+
/**
|
|
2078
|
+
* Checks that a path walks relationship names from the built-in user, in at most
|
|
2079
|
+
* {@link MAX_ME_HOPS} hops, and ends on `expected`, the entity whose ids the field compares.
|
|
2080
|
+
*/
|
|
2081
|
+
function checkMePath(walk, hops, expected, field, path) {
|
|
2082
|
+
const text = mePathText(hops);
|
|
2083
|
+
if (hops.length > MAX_ME_HOPS) {
|
|
2084
|
+
scopeProblem(walk, "invalid-scope-path", path, `${text} takes ${hops.length} relationships; a path takes at most ${MAX_ME_HOPS} from the ` +
|
|
2085
|
+
"user. Keep a relationship closer to the user, maintained by a workflow, and walk that.");
|
|
2086
|
+
return;
|
|
2087
|
+
}
|
|
2088
|
+
let current = User.name;
|
|
2089
|
+
for (const hop of hops) {
|
|
2090
|
+
const links = queryLinks(walk.manifest, current);
|
|
2091
|
+
const link = links.find((candidate) => candidate.name === hop);
|
|
2092
|
+
if (link === undefined) {
|
|
2093
|
+
const names = links.map((candidate) => toCamelCase(candidate.name));
|
|
2094
|
+
scopeProblem(walk, "invalid-scope-path", path, `${toCamelCase(hop)} in ${text} is not a relationship of ${current}; each hop names a ` +
|
|
2095
|
+
`relationship as seen from the entity before it, ${names.length === 0 ? `and ${current} has none` : `here one of ${names.join(", ")}`}.`);
|
|
2096
|
+
return;
|
|
2097
|
+
}
|
|
2098
|
+
current = link.target;
|
|
2099
|
+
}
|
|
2100
|
+
if (expected !== undefined && current !== expected) {
|
|
2101
|
+
scopeProblem(walk, "invalid-scope-path", path, `${text} ends on ${current}, but ${field} compares ${expected} ids; end the path on ` +
|
|
2102
|
+
`${expected}, or compare it with a relationship to ${current} or with id on ${current}.`);
|
|
2103
|
+
}
|
|
2104
|
+
}
|
|
2105
|
+
/**
|
|
2106
|
+
* A comparison of a field with `me`, a path or literal values, checked, with every marker replaced
|
|
2107
|
+
* by an id so that the query schema can validate the rest. `target` is the entity whose ids the
|
|
2108
|
+
* field holds: the entity itself for `id`, the related entity for a `<relationship>_id`, the user
|
|
2109
|
+
* for `created_by` and `updated_by`; `undefined` for any other field.
|
|
2110
|
+
*/
|
|
2111
|
+
function scopeComparison(input) {
|
|
2112
|
+
const { walk, field, target, name, condition, path } = input;
|
|
2113
|
+
const ids = field.storage === "uuid";
|
|
2114
|
+
const literalId = () => scopeProblem(walk, "invalid-scope", path, `${name} compares record ids, which differ between environments, with a literal value; ` +
|
|
2115
|
+
"compare it with me or a path, such as { in: me.staffMember.division }, or declare a " +
|
|
2116
|
+
"role per unit.");
|
|
2117
|
+
const marker = (value, operator) => {
|
|
2118
|
+
const hops = mePathOf(value);
|
|
2119
|
+
if (hops === undefined) {
|
|
2120
|
+
return value;
|
|
2121
|
+
}
|
|
2122
|
+
if (!ids || target === undefined) {
|
|
2123
|
+
scopeProblem(walk, "invalid-scope", path, `${mePathText(hops)} stands for record ids, and ${name} holds none; compare me with a ` +
|
|
2124
|
+
"relationship to user or with id, and a path with a relationship to the entity it " +
|
|
2125
|
+
"ends on.");
|
|
2126
|
+
return SCOPE_PLACEHOLDER_ID;
|
|
2127
|
+
}
|
|
2128
|
+
if (operator === "in") {
|
|
2129
|
+
if (hops.length === 0) {
|
|
2130
|
+
scopeProblem(walk, "invalid-scope", path, `me is one id and does not take in; write ${name}: me or { eq: me }, or a path such ` +
|
|
2131
|
+
"as { in: me.staffMember.division } for a set.");
|
|
2132
|
+
}
|
|
2133
|
+
else {
|
|
2134
|
+
checkMePath(walk, hops, target, name, path);
|
|
2135
|
+
}
|
|
2136
|
+
return [SCOPE_PLACEHOLDER_ID];
|
|
2137
|
+
}
|
|
2138
|
+
if (operator !== "eq" && operator !== "ne") {
|
|
2139
|
+
scopeProblem(walk, "invalid-scope", path, `${operator} does not compare with ${mePathText(hops)}; compare me with eq or ne, and a ` +
|
|
2140
|
+
"path with in.");
|
|
2141
|
+
}
|
|
2142
|
+
else if (hops.length > 0) {
|
|
2143
|
+
scopeProblem(walk, "invalid-scope", path, `${mePathText(hops)} is a set of ids and does not take ${operator}; write ` +
|
|
2144
|
+
`{ in: ${mePathText(hops)} }.`);
|
|
2145
|
+
}
|
|
2146
|
+
else {
|
|
2147
|
+
checkMePath(walk, hops, target, name, path);
|
|
2148
|
+
}
|
|
2149
|
+
return SCOPE_PLACEHOLDER_ID;
|
|
2150
|
+
};
|
|
2151
|
+
if (mePathOf(condition) !== undefined) {
|
|
2152
|
+
return marker(condition, "eq");
|
|
2153
|
+
}
|
|
2154
|
+
if (isFieldOperators(condition)) {
|
|
2155
|
+
return Object.fromEntries(Object.entries(condition).map(([operator, value]) => {
|
|
2156
|
+
if (mePathOf(value) !== undefined) {
|
|
2157
|
+
return [operator, marker(value, operator)];
|
|
2158
|
+
}
|
|
2159
|
+
if (ids &&
|
|
2160
|
+
(operator === "eq" || operator === "ne" || operator === "in") &&
|
|
2161
|
+
value !== null &&
|
|
2162
|
+
value !== undefined) {
|
|
2163
|
+
literalId();
|
|
2164
|
+
}
|
|
2165
|
+
return [operator, value];
|
|
2166
|
+
}));
|
|
2167
|
+
}
|
|
2168
|
+
if (ids && condition !== null && condition !== undefined && !isPlainObject(condition)) {
|
|
2169
|
+
literalId();
|
|
2170
|
+
}
|
|
2171
|
+
return condition;
|
|
2172
|
+
}
|
|
2173
|
+
/** Reports an empty condition, which in a scope would match every record while counting as scoped. */
|
|
2174
|
+
function emptyScopeCondition(walk, condition, path) {
|
|
2175
|
+
const empty = condition === undefined ||
|
|
2176
|
+
(isPlainObject(condition) && Object.keys(condition).length === 0) ||
|
|
2177
|
+
(Array.isArray(condition) && condition.length === 0);
|
|
2178
|
+
if (empty) {
|
|
2179
|
+
scopeProblem(walk, "invalid-scope", path, "an empty condition matches every record, so the scope would grant them all while " +
|
|
2180
|
+
"counting as scoped; write the condition, or list the permission as a string to grant " +
|
|
2181
|
+
"every record.");
|
|
2182
|
+
}
|
|
2183
|
+
return empty;
|
|
2184
|
+
}
|
|
2185
|
+
/**
|
|
2186
|
+
* Walks a scope's `where` on an entity: checks every comparison with `me`, paths and record ids,
|
|
2187
|
+
* and returns the `where` the query schema validates, with a relationship compared by key renamed
|
|
2188
|
+
* to its `<relationship>_id` and every marker replaced by an id. An empty `where`, field filter,
|
|
2189
|
+
* relationship filter or `and` is refused; `some: {}` and `none: {}` ask whether a related record
|
|
2190
|
+
* exists and are accepted.
|
|
2191
|
+
*/
|
|
2192
|
+
function scopeWhere(walk, entityName, where, path, anyRecord = false) {
|
|
2193
|
+
const entity = findEntityManifest(walk.manifest, entityName);
|
|
2194
|
+
if (where === undefined) {
|
|
2195
|
+
emptyScopeCondition(walk, where, path);
|
|
2196
|
+
return where;
|
|
2197
|
+
}
|
|
2198
|
+
if (!isPlainObject(where) || entity === undefined) {
|
|
2199
|
+
return where;
|
|
2200
|
+
}
|
|
2201
|
+
if (!anyRecord && emptyScopeCondition(walk, where, path)) {
|
|
2202
|
+
return where;
|
|
2203
|
+
}
|
|
2204
|
+
const fields = new Map(queryFields(entity).map((field) => [field.name, field]));
|
|
2205
|
+
const links = new Map(queryLinks(walk.manifest, entityName).map((link) => [link.name, link]));
|
|
2206
|
+
const keyTarget = (field) => {
|
|
2207
|
+
if (field.name === "id") {
|
|
2208
|
+
return entityName;
|
|
2209
|
+
}
|
|
2210
|
+
if (field.name === "created_by" || field.name === "updated_by") {
|
|
2211
|
+
return User.name;
|
|
2212
|
+
}
|
|
2213
|
+
const link = field.name.endsWith("_id") ? links.get(field.name.slice(0, -3)) : undefined;
|
|
2214
|
+
return link !== undefined && link.cardinality === "one" && !link.inverse
|
|
2215
|
+
? link.target
|
|
2216
|
+
: undefined;
|
|
2217
|
+
};
|
|
2218
|
+
const result = {};
|
|
2219
|
+
for (const [key, condition] of Object.entries(where)) {
|
|
2220
|
+
const at = `${path}.${key}`;
|
|
2221
|
+
const field = fields.get(key);
|
|
2222
|
+
const link = links.get(key);
|
|
2223
|
+
// `or: []` matches nothing, which is narrow, not wide.
|
|
2224
|
+
if (!(key === "or" && Array.isArray(condition)) && emptyScopeCondition(walk, condition, at)) {
|
|
2225
|
+
continue;
|
|
2226
|
+
}
|
|
2227
|
+
if ((key === "and" || key === "or") && Array.isArray(condition)) {
|
|
2228
|
+
result[key] = condition.map((child, index) => scopeWhere(walk, entityName, child, `${at}[${index}]`));
|
|
2229
|
+
}
|
|
2230
|
+
else if (key === "not") {
|
|
2231
|
+
result[key] = scopeWhere(walk, entityName, condition, at);
|
|
2232
|
+
}
|
|
2233
|
+
else if (field !== undefined) {
|
|
2234
|
+
result[key] = scopeComparison({
|
|
2235
|
+
walk,
|
|
2236
|
+
field,
|
|
2237
|
+
target: keyTarget(field),
|
|
2238
|
+
name: key,
|
|
2239
|
+
condition,
|
|
2240
|
+
path: at,
|
|
2241
|
+
});
|
|
2242
|
+
}
|
|
2243
|
+
else if (link !== undefined) {
|
|
2244
|
+
const compared = mePathOf(condition) !== undefined || isFieldOperators(condition);
|
|
2245
|
+
const key_ = fields.get(`${key}_id`);
|
|
2246
|
+
if (compared && link.cardinality === "one" && key_ !== undefined) {
|
|
2247
|
+
result[key_.name] = scopeComparison({
|
|
2248
|
+
walk,
|
|
2249
|
+
field: key_,
|
|
2250
|
+
target: link.target,
|
|
2251
|
+
name: key,
|
|
2252
|
+
condition,
|
|
2253
|
+
path: at,
|
|
2254
|
+
});
|
|
2255
|
+
}
|
|
2256
|
+
else if (compared) {
|
|
2257
|
+
scopeProblem(walk, "invalid-scope", at, `${key} relates many ${link.target} records and is not compared with ids; filter ` +
|
|
2258
|
+
`the related records instead, as in { ${key}: { some: { id: { in: me.… } } } }.`);
|
|
2259
|
+
}
|
|
2260
|
+
else if (link.cardinality === "one") {
|
|
2261
|
+
result[key] = scopeWhere(walk, link.target, condition, at);
|
|
2262
|
+
}
|
|
2263
|
+
else if (isPlainObject(condition)) {
|
|
2264
|
+
result[key] = Object.fromEntries(Object.entries(condition).map(([quantifier, child]) => [
|
|
2265
|
+
quantifier,
|
|
2266
|
+
scopeWhere(walk, link.target, child, `${at}.${quantifier}`, quantifier === "some" || quantifier === "none"),
|
|
2267
|
+
]));
|
|
2268
|
+
}
|
|
2269
|
+
else {
|
|
2270
|
+
result[key] = condition;
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
else {
|
|
2274
|
+
result[key] = condition;
|
|
2275
|
+
}
|
|
2276
|
+
}
|
|
2277
|
+
return result;
|
|
2278
|
+
}
|
|
2279
|
+
/** Whether an authored action cannot carry a scope: it writes nothing a scope could check. */
|
|
2280
|
+
function writesNothing(candidate) {
|
|
2281
|
+
return (candidate.hints?.readOnly === true ||
|
|
2282
|
+
(candidate.action.prepare !== undefined && candidate.action.transactional === false));
|
|
2283
|
+
}
|
|
2284
|
+
function checkScope(input) {
|
|
2285
|
+
const { scope, path, entities, verbs, report } = input;
|
|
2286
|
+
if (!isPlainObject(scope) ||
|
|
2287
|
+
typeof scope.permission !== "string" ||
|
|
2288
|
+
!isPlainObject(scope.where) ||
|
|
2289
|
+
mePathOf(scope.where) !== undefined ||
|
|
2290
|
+
Object.keys(scope).some((key) => !SCOPE_KEYS.includes(key)) ||
|
|
2291
|
+
(scope.readsAll !== undefined && scope.readsAll !== true)) {
|
|
2292
|
+
report("invalid-scope", path, 'a scoped permission is { permission: "<entity>:<verb>", where: { … } }, optionally with ' +
|
|
2293
|
+
"readsAll: true; write it so, or list the permission as a string to grant every record.");
|
|
2294
|
+
return;
|
|
2295
|
+
}
|
|
2296
|
+
const permission = scope.permission;
|
|
2297
|
+
if (parseWorkflowPermission(permission) !== undefined) {
|
|
2298
|
+
report("invalid-scope", path, `${permission} is a workflow permission, which has no records to scope; list it as a string.`);
|
|
2299
|
+
return;
|
|
2300
|
+
}
|
|
2301
|
+
const parsed = parsePermission(permission);
|
|
2302
|
+
// An invalid or unknown permission is already an error of the role's permissions.
|
|
2303
|
+
if (parsed === undefined) {
|
|
2304
|
+
return;
|
|
2305
|
+
}
|
|
2306
|
+
if (parsed.entity === WILDCARD || parsed.verb === WILDCARD) {
|
|
2307
|
+
report("invalid-scope", path, `${permission} has a wildcard, and a scope names one entity and one verb; scope each ` +
|
|
2308
|
+
"permission by name, such as staff_member:update, or list the wildcard as a string.");
|
|
2309
|
+
return;
|
|
2310
|
+
}
|
|
2311
|
+
const entity = entities.find((candidate) => candidate.name === parsed.entity);
|
|
2312
|
+
if (entity === undefined || verbs.get(parsed.entity)?.has(parsed.verb) !== true) {
|
|
2313
|
+
return;
|
|
2314
|
+
}
|
|
2315
|
+
if (entity.entity.definition.source !== undefined) {
|
|
2316
|
+
report("invalid-scope", path, `${parsed.entity} is warehouse-backed, and scopes apply to stored entities only; list ` +
|
|
2317
|
+
`${permission} as a string in the roles that may read every row.`);
|
|
2318
|
+
return;
|
|
2319
|
+
}
|
|
2320
|
+
// Member writes (`grantRoles`, `invite`, …) do not pass through the write guard, and an authored
|
|
2321
|
+
// `User` action may make them, so only reads of the built-in user are scoped.
|
|
2322
|
+
if (entity.builtIn && entity.name === User.name && parsed.verb !== READ_VERB) {
|
|
2323
|
+
report("invalid-scope", path, `${permission} may administer members, which a scope does not check; scope only ` +
|
|
2324
|
+
"user:read, and list user actions as strings in the roles that may run them on every member.");
|
|
2325
|
+
return;
|
|
2326
|
+
}
|
|
2327
|
+
const unscopable = entities
|
|
2328
|
+
.flatMap((candidate) => candidate.actions.map((action) => ({ entity: candidate.name, action })))
|
|
2329
|
+
.find(({ action }) => action.permission === permission && writesNothing(action));
|
|
2330
|
+
if (unscopable !== undefined) {
|
|
2331
|
+
report("invalid-scope", path, `${permission} runs ${unscopable.entity}.${unscopable.action.name}, a readOnly or ` +
|
|
2332
|
+
"prepare-only action that writes nothing a scope could check; list it as a string, or " +
|
|
2333
|
+
"give the action a handler that writes the record.");
|
|
2334
|
+
return;
|
|
2335
|
+
}
|
|
2336
|
+
const manifest = input.manifest();
|
|
2337
|
+
if (manifest === undefined) {
|
|
2338
|
+
return;
|
|
2339
|
+
}
|
|
2340
|
+
const walk = { manifest, report, problems: 0 };
|
|
2341
|
+
const where = scopeWhere(walk, parsed.entity, scope.where, `${path}.where`);
|
|
2342
|
+
if (walk.problems > 0) {
|
|
2343
|
+
return;
|
|
2344
|
+
}
|
|
2345
|
+
const validated = validate(entityQuerySchemas(manifest, parsed.entity, true).where, where);
|
|
2346
|
+
if (!validated.ok) {
|
|
2347
|
+
const issues = validated.issues
|
|
2348
|
+
.map((issue) => issue.path.length === 0 ? issue.message : `${issue.path.join(".")}: ${issue.message}`)
|
|
2349
|
+
.join("; ");
|
|
2350
|
+
report("invalid-scope", `${path}.where`, `the where is not a query of ${parsed.entity} (${issues}); write it as a find on ` +
|
|
2351
|
+
`${parsed.entity} would, with me for the caller.`);
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
/** A role's permissions its scoped entries do not name: those it grants every record of. */
|
|
2355
|
+
function unscopedPermissions(candidate) {
|
|
2356
|
+
const permissions = Array.isArray(candidate.permissions) ? candidate.permissions : [];
|
|
2357
|
+
const scopes = Array.isArray(candidate.scopes) ? candidate.scopes : [];
|
|
2358
|
+
const scoped = new Set(scopes.map((scope) => isPlainObject(scope) && typeof scope.permission === "string"
|
|
2359
|
+
? normalizePermission(scope.permission)
|
|
2360
|
+
: undefined));
|
|
2361
|
+
return permissions.filter((permission) => typeof permission === "string" && !scoped.has(normalizePermission(permission)));
|
|
2362
|
+
}
|
|
2363
|
+
/**
|
|
2364
|
+
* Checks every scoped permission of the roles; warns about a scope that never applies because the
|
|
2365
|
+
* role grants the same permission unscoped, and about a role that scopes a write of an entity while
|
|
2366
|
+
* reading every record of it. A scope `member` grants unscoped is only noted: it narrows nothing for
|
|
2367
|
+
* members, but applies to API keys, which hold exactly the roles they were given.
|
|
2368
|
+
*/
|
|
2369
|
+
function checkScopes(entities, roles, verbs, report) {
|
|
2370
|
+
if (!roles.some((candidate) => (Array.isArray(candidate.scopes) && candidate.scopes.length > 0) ||
|
|
2371
|
+
(candidate.unusedScopes ?? []).length > 0)) {
|
|
2372
|
+
return;
|
|
2373
|
+
}
|
|
2374
|
+
const memberRole = roles.find((candidate) => candidate.name === MEMBER_ROLE);
|
|
2375
|
+
const memberGrants = memberRole === undefined ? [] : unscopedPermissions(memberRole);
|
|
2376
|
+
let built;
|
|
2377
|
+
// The manifest of the entities, for their query schemas; a definition it cannot be built from
|
|
2378
|
+
// has errors of its own.
|
|
2379
|
+
const manifest = () => {
|
|
2380
|
+
if (built === undefined) {
|
|
2381
|
+
try {
|
|
2382
|
+
built = buildManifest({ entities, roles: [], signIn: { policy: DEFAULT_SIGN_IN_POLICY } });
|
|
2383
|
+
}
|
|
2384
|
+
catch {
|
|
2385
|
+
built = null;
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
return built ?? undefined;
|
|
2389
|
+
};
|
|
2390
|
+
for (const candidate of roles) {
|
|
2391
|
+
const scopes = Array.isArray(candidate.scopes) ? candidate.scopes : [];
|
|
2392
|
+
const base = candidate.name === MEMBER_ROLE ? MEMBER_ROLE : `roles.${candidate.name}`;
|
|
2393
|
+
const unscoped = unscopedPermissions(candidate);
|
|
2394
|
+
const unused = (permission, grant, by, path) => report("scope-unused", path, `${candidate.name} scopes ${permission}, but ${by} grants it on every record through ` +
|
|
2395
|
+
`${grant}, so the scope never applies; remove ${grant} from ${by}, or the scoped entry ` +
|
|
2396
|
+
"if every record is intended.", "warning");
|
|
2397
|
+
(candidate.unusedScopes ?? []).forEach((scope) => {
|
|
2398
|
+
const grant = unscoped.find((permission) => normalizePermission(permission) === normalizePermission(scope.permission));
|
|
2399
|
+
unused(scope.permission, grant ?? scope.permission, `${candidate.name} itself`, base);
|
|
2400
|
+
});
|
|
2401
|
+
scopes.forEach((scope, index) => {
|
|
2402
|
+
const path = `${base}.scopes[${index}]`;
|
|
2403
|
+
checkScope({ scope, path, entities, verbs, manifest, report });
|
|
2404
|
+
const parsed = isPlainObject(scope) ? parsePermission(scope.permission) : undefined;
|
|
2405
|
+
if (parsed !== undefined && parsed.entity !== WILDCARD && parsed.verb !== WILDCARD) {
|
|
2406
|
+
const permission = scope.permission;
|
|
2407
|
+
const own = unscoped.find((granted) => grants(granted, permission));
|
|
2408
|
+
const fromMember = candidate.name === MEMBER_ROLE
|
|
2409
|
+
? undefined
|
|
2410
|
+
: memberGrants.find((granted) => grants(granted, permission));
|
|
2411
|
+
if (own !== undefined) {
|
|
2412
|
+
unused(permission, own, `${candidate.name} itself`, path);
|
|
2413
|
+
return;
|
|
2414
|
+
}
|
|
2415
|
+
if (fromMember !== undefined) {
|
|
2416
|
+
report("scope-member-overlap", path, `${candidate.name} scopes ${permission}, which ${MEMBER_ROLE} grants on every record ` +
|
|
2417
|
+
`through ${fromMember}, so the scope narrows nothing for members; it still applies ` +
|
|
2418
|
+
"to API keys holding this role.", "info");
|
|
2419
|
+
return;
|
|
2420
|
+
}
|
|
2421
|
+
}
|
|
2422
|
+
if (parsed === undefined ||
|
|
2423
|
+
parsed.entity === WILDCARD ||
|
|
2424
|
+
parsed.verb === WILDCARD ||
|
|
2425
|
+
parsed.verb === READ_VERB ||
|
|
2426
|
+
scope.readsAll === true) {
|
|
2427
|
+
return;
|
|
2428
|
+
}
|
|
2429
|
+
const read = permissionOf(parsed.entity, READ_VERB);
|
|
2430
|
+
const reading = unscoped.find((permission) => grants(permission, read));
|
|
2431
|
+
if (reading !== undefined) {
|
|
2432
|
+
report("scope-reads-all", path, `${candidate.name} scopes ${parsed.entity}:${parsed.verb} but reads every ` +
|
|
2433
|
+
`${parsed.entity} through ${reading}; scope ${read} with the same where, or set ` +
|
|
2434
|
+
"readsAll: true on this entry if reading every record is intended.", "warning");
|
|
2435
|
+
}
|
|
2436
|
+
});
|
|
2437
|
+
}
|
|
2438
|
+
}
|