@happyvertical/smrt-approvals 0.0.0-stage → 0.55.11
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/AGENTS.md +45 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +7 -0
- package/README.md +124 -2
- package/agents/approvals.md +93 -0
- package/dist/__smrt-register__.d.ts +2 -0
- package/dist/__smrt-register__.d.ts.map +1 -0
- package/dist/collections/ApprovalEventCollection.d.ts +13 -0
- package/dist/collections/ApprovalEventCollection.d.ts.map +1 -0
- package/dist/collections/ApprovalPolicyCollection.d.ts +13 -0
- package/dist/collections/ApprovalPolicyCollection.d.ts.map +1 -0
- package/dist/collections/ApprovalRequestCollection.d.ts +13 -0
- package/dist/collections/ApprovalRequestCollection.d.ts.map +1 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1307 -0
- package/dist/index.js.map +1 -0
- package/dist/kinds.d.ts +61 -0
- package/dist/kinds.d.ts.map +1 -0
- package/dist/manifest.json +1531 -0
- package/dist/models/ApprovalEvent.d.ts +82 -0
- package/dist/models/ApprovalEvent.d.ts.map +1 -0
- package/dist/models/ApprovalPolicy.d.ts +47 -0
- package/dist/models/ApprovalPolicy.d.ts.map +1 -0
- package/dist/models/ApprovalRequest.d.ts +118 -0
- package/dist/models/ApprovalRequest.d.ts.map +1 -0
- package/dist/permissions.d.ts +20 -0
- package/dist/permissions.d.ts.map +1 -0
- package/dist/service.d.ts +203 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/smrt-knowledge.json +751 -0
- package/dist/types.d.ts +136 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +45 -0
- package/dist/types.js.map +1 -0
- package/dist/write-capability.d.ts +28 -0
- package/dist/write-capability.d.ts.map +1 -0
- package/package.json +64 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,1307 @@
|
|
|
1
|
+
import { APPROVAL_EVENT_TYPES, APPROVAL_PRINCIPAL_TYPES, APPROVAL_STATUSES, ApprovalError, approvalPrincipalFromPermissions } from "./types.js";
|
|
2
|
+
import { ObjectRegistry, SmrtCollection, SmrtObject, appendChange, field, foreignKey, isEmbeddedDatabase, isUniqueViolationError, smrt, withEmbeddedWriteTransaction } from "@happyvertical/smrt-core";
|
|
3
|
+
import { registerPermissionDefinitions } from "@happyvertical/smrt-users";
|
|
4
|
+
import { TenantScoped, getTenantId, isSystemContext, tenantId, withTenant } from "@happyvertical/smrt-tenancy";
|
|
5
|
+
//#region src/__smrt-register__.ts
|
|
6
|
+
ObjectRegistry.registerPackageManifest(JSON.parse(`{"version":"1.0.0","timestamp":0,"packageName":"@happyvertical/smrt-approvals","packageVersion":"0.55.11","objects":{"@happyvertical/smrt-approvals:ApprovalEventCollection":{"name":"approvaleventcollection","className":"ApprovalEventCollection","qualifiedName":"@happyvertical/smrt-approvals:ApprovalEventCollection","collection":"approvalevents","filePath":"/home/runner/work/smrt/smrt/packages/approvals/src/collections/ApprovalEventCollection.ts","packageName":"@happyvertical/smrt-approvals","fields":{},"methods":{},"decoratorConfig":{"api":{"include":["list","get"]},"mcp":{"include":["list","get"]},"cli":false,"tableName":"approval_events"},"extends":"SmrtCollection","extendsTypeArg":"ApprovalEvent","exportName":"ApprovalEventCollection","collectionExportName":"ApprovalEventCollectionCollection","schema":{"tableName":"approval_events","ddl":"CREATE TABLE IF NOT EXISTS \\"approval_events\\" (\\n \\"id\\" UUID PRIMARY KEY NOT NULL,\\n \\"slug\\" TEXT NOT NULL,\\n \\"context\\" TEXT NOT NULL DEFAULT '',\\n \\"created_at\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\"updated_at\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\"tenant_id\\" UUID NOT NULL,\\n \\"request_id\\" UUID NOT NULL,\\n \\"type\\" TEXT NOT NULL,\\n \\"actor_id\\" TEXT NOT NULL,\\n \\"actor_type\\" TEXT,\\n \\"reason\\" TEXT,\\n \\"subject_revision_hash\\" TEXT,\\n \\"sequence\\" INTEGER,\\n \\"vote_key\\" TEXT,\\n \\"occurred_at\\" TIMESTAMP\\n);","columns":{"id":{"type":"UUID","primaryKey":true,"referenceKind":"id","notNull":true},"slug":{"type":"TEXT","notNull":true},"context":{"type":"TEXT","notNull":true,"default":""},"created_at":{"type":"TIMESTAMP","notNull":true,"default":"current_timestamp"},"updated_at":{"type":"TIMESTAMP","notNull":true,"default":"current_timestamp"},"tenant_id":{"type":"UUID","referenceKind":"tenantId","notNull":true,"unique":false},"request_id":{"type":"UUID","referenceKind":"foreignKey","notNull":true,"unique":false,"foreignKey":{"table":"approval_requests","column":"id","onDelete":"RESTRICT","onUpdate":"RESTRICT","engines":["postgres","sqlite"]}},"type":{"type":"TEXT","notNull":true,"unique":false},"actor_id":{"type":"TEXT","notNull":true,"unique":false},"actor_type":{"type":"TEXT","notNull":false,"unique":false},"reason":{"type":"TEXT","notNull":false,"unique":false},"subject_revision_hash":{"type":"TEXT","notNull":false,"unique":false},"sequence":{"type":"INTEGER","notNull":false,"unique":false},"vote_key":{"type":"TEXT","notNull":false,"unique":false},"occurred_at":{"type":"TIMESTAMP","notNull":false,"unique":false}},"indexes":[{"name":"approval_events_slug_context_idx","columns":["tenant_id","slug","context"],"unique":true},{"name":"approval_events_request_sequence_key","columns":["request_id","sequence"],"unique":true},{"name":"approval_events_request_vote_key","columns":["request_id","vote_key"],"unique":true},{"name":"approval_events_tenant_occurred_idx","columns":["tenant_id","occurred_at"]},{"name":"approval_events_tenant_id_created_at_idx","columns":["tenant_id","created_at"]}],"version":"4963053a"},"importPath":"@happyvertical/smrt-approvals"},"@happyvertical/smrt-approvals:ApprovalPolicyCollection":{"name":"approvalpolicycollection","className":"ApprovalPolicyCollection","qualifiedName":"@happyvertical/smrt-approvals:ApprovalPolicyCollection","collection":"approvalpolicies","filePath":"/home/runner/work/smrt/smrt/packages/approvals/src/collections/ApprovalPolicyCollection.ts","packageName":"@happyvertical/smrt-approvals","fields":{},"methods":{},"decoratorConfig":{"api":{"include":["list","get"]},"mcp":{"include":["list","get"]},"cli":false,"tableName":"approval_policies"},"extends":"SmrtCollection","extendsTypeArg":"ApprovalPolicy","exportName":"ApprovalPolicyCollection","collectionExportName":"ApprovalPolicyCollectionCollection","schema":{"tableName":"approval_policies","ddl":"CREATE TABLE IF NOT EXISTS \\"approval_policies\\" (\\n \\"id\\" UUID PRIMARY KEY NOT NULL,\\n \\"slug\\" TEXT NOT NULL,\\n \\"context\\" TEXT NOT NULL DEFAULT '',\\n \\"created_at\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\"updated_at\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\"tenant_id\\" UUID NOT NULL,\\n \\"kind\\" TEXT NOT NULL,\\n \\"required_approvals\\" INTEGER,\\n \\"ttl_ms\\" INTEGER,\\n \\"required_permission\\" TEXT,\\n \\"updated_by\\" TEXT\\n);","columns":{"id":{"type":"UUID","primaryKey":true,"referenceKind":"id","notNull":true},"slug":{"type":"TEXT","notNull":true},"context":{"type":"TEXT","notNull":true,"default":""},"created_at":{"type":"TIMESTAMP","notNull":true,"default":"current_timestamp"},"updated_at":{"type":"TIMESTAMP","notNull":true,"default":"current_timestamp"},"tenant_id":{"type":"UUID","referenceKind":"tenantId","notNull":true,"unique":false},"kind":{"type":"TEXT","notNull":true,"unique":false},"required_approvals":{"type":"INTEGER","notNull":false,"unique":false},"ttl_ms":{"type":"INTEGER","notNull":false,"unique":false},"required_permission":{"type":"TEXT","notNull":false,"unique":false},"updated_by":{"type":"TEXT","notNull":false,"unique":false}},"indexes":[{"name":"approval_policies_slug_context_idx","columns":["tenant_id","slug","context"],"unique":true},{"name":"approval_policies_tenant_kind_key","columns":["tenant_id","kind"],"unique":true},{"name":"approval_policies_tenant_id_created_at_idx","columns":["tenant_id","created_at"]}],"version":"80929cc8"},"importPath":"@happyvertical/smrt-approvals"},"@happyvertical/smrt-approvals:ApprovalRequestCollection":{"name":"approvalrequestcollection","className":"ApprovalRequestCollection","qualifiedName":"@happyvertical/smrt-approvals:ApprovalRequestCollection","collection":"approvalrequests","filePath":"/home/runner/work/smrt/smrt/packages/approvals/src/collections/ApprovalRequestCollection.ts","packageName":"@happyvertical/smrt-approvals","fields":{},"methods":{},"decoratorConfig":{"api":{"include":["list","get"]},"mcp":{"include":["list","get"]},"cli":false,"tableName":"approval_requests"},"extends":"SmrtCollection","extendsTypeArg":"ApprovalRequest","exportName":"ApprovalRequestCollection","collectionExportName":"ApprovalRequestCollectionCollection","schema":{"tableName":"approval_requests","ddl":"CREATE TABLE IF NOT EXISTS \\"approval_requests\\" (\\n \\"id\\" UUID PRIMARY KEY NOT NULL,\\n \\"slug\\" TEXT NOT NULL,\\n \\"context\\" TEXT NOT NULL DEFAULT '',\\n \\"created_at\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\"updated_at\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\"tenant_id\\" UUID NOT NULL,\\n \\"kind\\" TEXT NOT NULL,\\n \\"subject_type\\" TEXT NOT NULL,\\n \\"subject_id\\" TEXT NOT NULL,\\n \\"subject_revision_hash\\" TEXT NOT NULL,\\n \\"request_key\\" TEXT NOT NULL,\\n \\"requested_by\\" TEXT NOT NULL,\\n \\"requested_by_type\\" TEXT,\\n \\"summary\\" TEXT,\\n \\"status\\" TEXT,\\n \\"required_approvals\\" INTEGER,\\n \\"approval_count\\" INTEGER,\\n \\"required_permissions\\" TEXT,\\n \\"version\\" INTEGER,\\n \\"expires_at\\" TIMESTAMP,\\n \\"decided_at\\" TIMESTAMP,\\n \\"consumed_at\\" TIMESTAMP,\\n \\"consumed_by\\" TEXT\\n);","columns":{"id":{"type":"UUID","primaryKey":true,"referenceKind":"id","notNull":true},"slug":{"type":"TEXT","notNull":true},"context":{"type":"TEXT","notNull":true,"default":""},"created_at":{"type":"TIMESTAMP","notNull":true,"default":"current_timestamp"},"updated_at":{"type":"TIMESTAMP","notNull":true,"default":"current_timestamp"},"tenant_id":{"type":"UUID","referenceKind":"tenantId","notNull":true,"unique":false},"kind":{"type":"TEXT","notNull":true,"unique":false},"subject_type":{"type":"TEXT","notNull":true,"unique":false},"subject_id":{"type":"TEXT","notNull":true,"unique":false},"subject_revision_hash":{"type":"TEXT","notNull":true,"unique":false},"request_key":{"type":"TEXT","notNull":true,"unique":false},"requested_by":{"type":"TEXT","notNull":true,"unique":false},"requested_by_type":{"type":"TEXT","notNull":false,"unique":false},"summary":{"type":"TEXT","notNull":false,"unique":false},"status":{"type":"TEXT","notNull":false,"unique":false},"required_approvals":{"type":"INTEGER","notNull":false,"unique":false},"approval_count":{"type":"INTEGER","notNull":false,"unique":false},"required_permissions":{"type":"TEXT","notNull":false,"unique":false},"version":{"type":"INTEGER","notNull":false,"unique":false},"expires_at":{"type":"TIMESTAMP","notNull":false,"unique":false},"decided_at":{"type":"TIMESTAMP","notNull":false,"unique":false},"consumed_at":{"type":"TIMESTAMP","notNull":false,"unique":false},"consumed_by":{"type":"TEXT","notNull":false,"unique":false}},"indexes":[{"name":"approval_requests_slug_context_idx","columns":["tenant_id","slug","context"],"unique":true},{"name":"approval_requests_tenant_request_key","columns":["tenant_id","request_key"],"unique":true},{"name":"approval_requests_status_expiry_idx","columns":["tenant_id","status","expires_at"]},{"name":"approval_requests_subject_idx","columns":["tenant_id","subject_type","subject_id"]},{"name":"approval_requests_tenant_id_created_at_idx","columns":["tenant_id","created_at"]}],"version":"a5800bdd"},"importPath":"@happyvertical/smrt-approvals"},"@happyvertical/smrt-approvals:ApprovalEvent":{"name":"approvalevent","className":"ApprovalEvent","qualifiedName":"@happyvertical/smrt-approvals:ApprovalEvent","collection":"approvalevents","description":"One immutable entry in an approval request's history.","filePath":"/home/runner/work/smrt/smrt/packages/approvals/src/models/ApprovalEvent.ts","packageName":"@happyvertical/smrt-approvals","fields":{"tenantId":{"type":"text","required":true,"_meta":{"sqlType":"UUID","__tenancy":{"isTenantIdField":true,"autoFilter":true,"required":true,"autoPopulate":true,"nullable":false,"mode":"required","field":"tenantId","allowSuperAdminBypass":false}}},"requestId":{"type":"foreignKey","required":true,"related":"ApprovalRequest","_meta":{"required":true,"constraint":{"engines":["postgres","sqlite"]},"onDelete":"RESTRICT","onUpdate":"RESTRICT"}},"type":{"type":"text","required":true,"_meta":{"required":true,"readonly":true},"readonly":true},"actorId":{"type":"text","required":true,"_meta":{"required":true,"readonly":true},"readonly":true},"actorType":{"type":"text","required":false,"_meta":{"readonly":true},"enum":["human","agent","service"],"readonly":true},"reason":{"type":"text","required":false,"_meta":{"readonly":true},"readonly":true},"subjectRevisionHash":{"type":"text","required":false,"_meta":{"readonly":true},"readonly":true},"sequence":{"type":"integer","required":false,"_meta":{"readonly":true},"readonly":true},"voteKey":{"type":"text","required":false,"_meta":{"nullable":true,"readonly":true},"readonly":true},"occurredAt":{"type":"datetime","required":false,"_meta":{"readonly":true},"readonly":true}},"methods":{"save":{"name":"save","async":true,"parameters":[],"returnType":"Promise<this>","isStatic":false,"isPublic":true},"delete":{"name":"delete","async":true,"parameters":[],"returnType":"Promise<void>","isStatic":false,"isPublic":true}},"decoratorConfig":{"tableName":"approval_events","api":{"include":["list","get"]},"mcp":{"include":["list","get"]},"cli":false,"indexes":[{"name":"approval_events_request_sequence_key","columns":["requestId","sequence"],"unique":true},{"name":"approval_events_request_vote_key","columns":["requestId","voteKey"],"unique":true},{"name":"approval_events_tenant_occurred_idx","columns":["tenantId","occurredAt"]}],"tenantScoped":{"mode":"required"},"conflictColumns":["tenant_id","slug","context"]},"extends":"SmrtObject","exportName":"ApprovalEvent","collectionExportName":"ApprovalEventCollection","validationRules":[{"field":"requestId","rule":"required","fieldType":"foreignKey"},{"field":"type","rule":"required","fieldType":"text"},{"field":"actorId","rule":"required","fieldType":"text"}],"schema":{"tableName":"approval_events","ddl":"CREATE TABLE IF NOT EXISTS \\"approval_events\\" (\\n \\"id\\" UUID PRIMARY KEY NOT NULL,\\n \\"slug\\" TEXT NOT NULL,\\n \\"context\\" TEXT NOT NULL DEFAULT '',\\n \\"created_at\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\"updated_at\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\"tenant_id\\" UUID NOT NULL,\\n \\"request_id\\" UUID NOT NULL,\\n \\"type\\" TEXT NOT NULL,\\n \\"actor_id\\" TEXT NOT NULL,\\n \\"actor_type\\" TEXT,\\n \\"reason\\" TEXT,\\n \\"subject_revision_hash\\" TEXT,\\n \\"sequence\\" INTEGER,\\n \\"vote_key\\" TEXT,\\n \\"occurred_at\\" TIMESTAMP\\n);","columns":{"id":{"type":"UUID","primaryKey":true,"referenceKind":"id","notNull":true},"slug":{"type":"TEXT","notNull":true},"context":{"type":"TEXT","notNull":true,"default":""},"created_at":{"type":"TIMESTAMP","notNull":true,"default":"current_timestamp"},"updated_at":{"type":"TIMESTAMP","notNull":true,"default":"current_timestamp"},"tenant_id":{"type":"UUID","referenceKind":"tenantId","notNull":true,"unique":false},"request_id":{"type":"UUID","referenceKind":"foreignKey","notNull":true,"unique":false,"foreignKey":{"table":"approval_requests","column":"id","onDelete":"RESTRICT","onUpdate":"RESTRICT","engines":["postgres","sqlite"]}},"type":{"type":"TEXT","notNull":true,"unique":false},"actor_id":{"type":"TEXT","notNull":true,"unique":false},"actor_type":{"type":"TEXT","notNull":false,"unique":false},"reason":{"type":"TEXT","notNull":false,"unique":false},"subject_revision_hash":{"type":"TEXT","notNull":false,"unique":false},"sequence":{"type":"INTEGER","notNull":false,"unique":false},"vote_key":{"type":"TEXT","notNull":false,"unique":false},"occurred_at":{"type":"TIMESTAMP","notNull":false,"unique":false}},"indexes":[{"name":"approval_events_slug_context_idx","columns":["tenant_id","slug","context"],"unique":true},{"name":"approval_events_request_sequence_key","columns":["request_id","sequence"],"unique":true},{"name":"approval_events_request_vote_key","columns":["request_id","vote_key"],"unique":true},{"name":"approval_events_tenant_occurred_idx","columns":["tenant_id","occurred_at"]},{"name":"approval_events_tenant_id_created_at_idx","columns":["tenant_id","created_at"]}],"version":"4963053a"},"importPath":"@happyvertical/smrt-approvals"},"@happyvertical/smrt-approvals:ApprovalPolicy":{"name":"approvalpolicy","className":"ApprovalPolicy","qualifiedName":"@happyvertical/smrt-approvals:ApprovalPolicy","collection":"approvalpolicies","description":"Tenant-level tightening of an approval kind. One row per tenant and kind.","filePath":"/home/runner/work/smrt/smrt/packages/approvals/src/models/ApprovalPolicy.ts","packageName":"@happyvertical/smrt-approvals","fields":{"tenantId":{"type":"text","required":true,"_meta":{"sqlType":"UUID","__tenancy":{"isTenantIdField":true,"autoFilter":true,"required":true,"autoPopulate":true,"nullable":false,"mode":"required","field":"tenantId","allowSuperAdminBypass":false}}},"kind":{"type":"text","required":true,"_meta":{"required":true}},"requiredApprovals":{"type":"integer","required":false,"_meta":{}},"ttlMs":{"type":"integer","required":false,"_meta":{}},"requiredPermission":{"type":"text","required":false,"_meta":{}},"updatedBy":{"type":"text","required":false,"_meta":{}}},"methods":{"save":{"name":"save","async":true,"parameters":[],"returnType":"Promise<this>","isStatic":false,"isPublic":true}},"decoratorConfig":{"tableName":"approval_policies","api":{"include":["list","get"]},"mcp":{"include":["list","get"]},"cli":false,"indexes":[{"name":"approval_policies_tenant_kind_key","columns":["tenantId","kind"],"unique":true}],"tenantScoped":{"mode":"required"},"conflictColumns":["tenant_id","slug","context"]},"extends":"SmrtObject","exportName":"ApprovalPolicy","collectionExportName":"ApprovalPolicyCollection","validationRules":[{"field":"kind","rule":"required","fieldType":"text"}],"schema":{"tableName":"approval_policies","ddl":"CREATE TABLE IF NOT EXISTS \\"approval_policies\\" (\\n \\"id\\" UUID PRIMARY KEY NOT NULL,\\n \\"slug\\" TEXT NOT NULL,\\n \\"context\\" TEXT NOT NULL DEFAULT '',\\n \\"created_at\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\"updated_at\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\"tenant_id\\" UUID NOT NULL,\\n \\"kind\\" TEXT NOT NULL,\\n \\"required_approvals\\" INTEGER,\\n \\"ttl_ms\\" INTEGER,\\n \\"required_permission\\" TEXT,\\n \\"updated_by\\" TEXT\\n);","columns":{"id":{"type":"UUID","primaryKey":true,"referenceKind":"id","notNull":true},"slug":{"type":"TEXT","notNull":true},"context":{"type":"TEXT","notNull":true,"default":""},"created_at":{"type":"TIMESTAMP","notNull":true,"default":"current_timestamp"},"updated_at":{"type":"TIMESTAMP","notNull":true,"default":"current_timestamp"},"tenant_id":{"type":"UUID","referenceKind":"tenantId","notNull":true,"unique":false},"kind":{"type":"TEXT","notNull":true,"unique":false},"required_approvals":{"type":"INTEGER","notNull":false,"unique":false},"ttl_ms":{"type":"INTEGER","notNull":false,"unique":false},"required_permission":{"type":"TEXT","notNull":false,"unique":false},"updated_by":{"type":"TEXT","notNull":false,"unique":false}},"indexes":[{"name":"approval_policies_slug_context_idx","columns":["tenant_id","slug","context"],"unique":true},{"name":"approval_policies_tenant_kind_key","columns":["tenant_id","kind"],"unique":true},{"name":"approval_policies_tenant_id_created_at_idx","columns":["tenant_id","created_at"]}],"version":"80929cc8"},"importPath":"@happyvertical/smrt-approvals"},"@happyvertical/smrt-approvals:ApprovalRequest":{"name":"approvalrequest","className":"ApprovalRequest","qualifiedName":"@happyvertical/smrt-approvals:ApprovalRequest","collection":"approvalrequests","description":"A request for approval of one subject revision.","filePath":"/home/runner/work/smrt/smrt/packages/approvals/src/models/ApprovalRequest.ts","packageName":"@happyvertical/smrt-approvals","fields":{"tenantId":{"type":"text","required":true,"_meta":{"sqlType":"UUID","__tenancy":{"isTenantIdField":true,"autoFilter":true,"required":true,"autoPopulate":true,"nullable":false,"mode":"required","field":"tenantId","allowSuperAdminBypass":false}}},"kind":{"type":"text","required":true,"_meta":{"required":true,"readonly":true},"readonly":true},"subjectType":{"type":"text","required":true,"_meta":{"required":true,"readonly":true},"readonly":true},"subjectId":{"type":"text","required":true,"_meta":{"required":true,"readonly":true},"readonly":true},"subjectRevisionHash":{"type":"text","required":true,"_meta":{"required":true,"readonly":true},"readonly":true},"requestKey":{"type":"text","required":true,"_meta":{"required":true,"readonly":true},"readonly":true},"requestedBy":{"type":"text","required":true,"_meta":{"required":true,"readonly":true},"readonly":true},"requestedByType":{"type":"text","required":false,"_meta":{"readonly":true},"enum":["human","agent","service"],"readonly":true},"summary":{"type":"text","required":false,"_meta":{"readonly":true},"readonly":true},"status":{"type":"text","required":false,"_meta":{"readonly":true},"readonly":true},"requiredApprovals":{"type":"integer","required":false,"_meta":{"readonly":true},"readonly":true},"approvalCount":{"type":"integer","required":false,"_meta":{"readonly":true},"readonly":true},"requiredPermissions":{"type":"text","required":false,"_meta":{"readonly":true},"readonly":true},"version":{"type":"integer","required":false,"_meta":{"readonly":true},"readonly":true},"expiresAt":{"type":"datetime","required":false,"_meta":{"readonly":true},"readonly":true},"decidedAt":{"type":"datetime","required":false,"_meta":{"readonly":true},"readonly":true},"consumedAt":{"type":"datetime","required":false,"_meta":{"readonly":true},"readonly":true},"consumedBy":{"type":"text","required":false,"_meta":{"readonly":true},"readonly":true}},"methods":{"getRequiredPermissions":{"name":"getRequiredPermissions","async":false,"parameters":[],"returnType":"string[]","isStatic":false,"isPublic":true},"setRequiredPermissions":{"name":"setRequiredPermissions","async":false,"parameters":[{"name":"slugs","type":"any","optional":false,"typeUnresolved":true}],"returnType":"void","isStatic":false,"isPublic":true},"expiresAtMs":{"name":"expiresAtMs","async":false,"parameters":[],"returnType":"number","isStatic":false,"isPublic":true},"isConsumed":{"name":"isConsumed","async":false,"parameters":[],"returnType":"boolean","isStatic":false,"isPublic":true},"save":{"name":"save","async":true,"parameters":[],"returnType":"Promise<this>","isStatic":false,"isPublic":true},"delete":{"name":"delete","async":true,"parameters":[],"returnType":"Promise<void>","isStatic":false,"isPublic":true}},"decoratorConfig":{"tableName":"approval_requests","api":{"include":["list","get"]},"mcp":{"include":["list","get"]},"cli":false,"indexes":[{"name":"approval_requests_tenant_request_key","columns":["tenantId","requestKey"],"unique":true},{"name":"approval_requests_status_expiry_idx","columns":["tenantId","status","expiresAt"]},{"name":"approval_requests_subject_idx","columns":["tenantId","subjectType","subjectId"]}],"tenantScoped":{"mode":"required"},"conflictColumns":["tenant_id","slug","context"]},"extends":"SmrtObject","exportName":"ApprovalRequest","collectionExportName":"ApprovalRequestCollection","validationRules":[{"field":"kind","rule":"required","fieldType":"text"},{"field":"subjectType","rule":"required","fieldType":"text"},{"field":"subjectId","rule":"required","fieldType":"text"},{"field":"subjectRevisionHash","rule":"required","fieldType":"text"},{"field":"requestKey","rule":"required","fieldType":"text"},{"field":"requestedBy","rule":"required","fieldType":"text"}],"schema":{"tableName":"approval_requests","ddl":"CREATE TABLE IF NOT EXISTS \\"approval_requests\\" (\\n \\"id\\" UUID PRIMARY KEY NOT NULL,\\n \\"slug\\" TEXT NOT NULL,\\n \\"context\\" TEXT NOT NULL DEFAULT '',\\n \\"created_at\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\"updated_at\\" TIMESTAMP NOT NULL DEFAULT current_timestamp,\\n \\"tenant_id\\" UUID NOT NULL,\\n \\"kind\\" TEXT NOT NULL,\\n \\"subject_type\\" TEXT NOT NULL,\\n \\"subject_id\\" TEXT NOT NULL,\\n \\"subject_revision_hash\\" TEXT NOT NULL,\\n \\"request_key\\" TEXT NOT NULL,\\n \\"requested_by\\" TEXT NOT NULL,\\n \\"requested_by_type\\" TEXT,\\n \\"summary\\" TEXT,\\n \\"status\\" TEXT,\\n \\"required_approvals\\" INTEGER,\\n \\"approval_count\\" INTEGER,\\n \\"required_permissions\\" TEXT,\\n \\"version\\" INTEGER,\\n \\"expires_at\\" TIMESTAMP,\\n \\"decided_at\\" TIMESTAMP,\\n \\"consumed_at\\" TIMESTAMP,\\n \\"consumed_by\\" TEXT\\n);","columns":{"id":{"type":"UUID","primaryKey":true,"referenceKind":"id","notNull":true},"slug":{"type":"TEXT","notNull":true},"context":{"type":"TEXT","notNull":true,"default":""},"created_at":{"type":"TIMESTAMP","notNull":true,"default":"current_timestamp"},"updated_at":{"type":"TIMESTAMP","notNull":true,"default":"current_timestamp"},"tenant_id":{"type":"UUID","referenceKind":"tenantId","notNull":true,"unique":false},"kind":{"type":"TEXT","notNull":true,"unique":false},"subject_type":{"type":"TEXT","notNull":true,"unique":false},"subject_id":{"type":"TEXT","notNull":true,"unique":false},"subject_revision_hash":{"type":"TEXT","notNull":true,"unique":false},"request_key":{"type":"TEXT","notNull":true,"unique":false},"requested_by":{"type":"TEXT","notNull":true,"unique":false},"requested_by_type":{"type":"TEXT","notNull":false,"unique":false},"summary":{"type":"TEXT","notNull":false,"unique":false},"status":{"type":"TEXT","notNull":false,"unique":false},"required_approvals":{"type":"INTEGER","notNull":false,"unique":false},"approval_count":{"type":"INTEGER","notNull":false,"unique":false},"required_permissions":{"type":"TEXT","notNull":false,"unique":false},"version":{"type":"INTEGER","notNull":false,"unique":false},"expires_at":{"type":"TIMESTAMP","notNull":false,"unique":false},"decided_at":{"type":"TIMESTAMP","notNull":false,"unique":false},"consumed_at":{"type":"TIMESTAMP","notNull":false,"unique":false},"consumed_by":{"type":"TEXT","notNull":false,"unique":false}},"indexes":[{"name":"approval_requests_slug_context_idx","columns":["tenant_id","slug","context"],"unique":true},{"name":"approval_requests_tenant_request_key","columns":["tenant_id","request_key"],"unique":true},{"name":"approval_requests_status_expiry_idx","columns":["tenant_id","status","expires_at"]},{"name":"approval_requests_subject_idx","columns":["tenant_id","subject_type","subject_id"]},{"name":"approval_requests_tenant_id_created_at_idx","columns":["tenant_id","created_at"]}],"version":"a5800bdd"},"importPath":"@happyvertical/smrt-approvals"}},"moduleType":"smrt","smrtDependencies":["@happyvertical/smrt-core","@happyvertical/smrt-tenancy","@happyvertical/smrt-users"],"browser":{"status":"server-only","issues":["#3617","#3624","#3626"],"via":["@happyvertical/smrt-profiles","@happyvertical/smrt-tenancy","@happyvertical/smrt-users"]},"stampsConstructors":true}`));
|
|
7
|
+
//#endregion
|
|
8
|
+
//#region src/permissions.ts
|
|
9
|
+
var CANCEL_ANY_APPROVAL_PERMISSION = "approvals.cancel-any";
|
|
10
|
+
var MANAGE_APPROVAL_POLICY_PERMISSION = "approvals.manage-policy";
|
|
11
|
+
var APPROVAL_PERMISSION_DEFINITIONS = [{
|
|
12
|
+
slug: CANCEL_ANY_APPROVAL_PERMISSION,
|
|
13
|
+
category: "approvals",
|
|
14
|
+
name: "Cancel Any Approval Request",
|
|
15
|
+
description: "Cancel a pending approval request made by someone else in the tenant"
|
|
16
|
+
}, {
|
|
17
|
+
slug: MANAGE_APPROVAL_POLICY_PERMISSION,
|
|
18
|
+
category: "approvals",
|
|
19
|
+
name: "Manage Approval Policy",
|
|
20
|
+
description: "Tighten an approval kind's quorum, expiry, or required permission for the tenant"
|
|
21
|
+
}];
|
|
22
|
+
var registered = false;
|
|
23
|
+
function registerApprovalPermissions() {
|
|
24
|
+
return registerPermissionDefinitions(APPROVAL_PERMISSION_DEFINITIONS);
|
|
25
|
+
}
|
|
26
|
+
function ensureApprovalPermissionsRegistered() {
|
|
27
|
+
if (registered) return;
|
|
28
|
+
registered = true;
|
|
29
|
+
registerApprovalPermissions();
|
|
30
|
+
}
|
|
31
|
+
//#endregion
|
|
32
|
+
//#region src/write-capability.ts
|
|
33
|
+
var SERVICE_WRITE = /* @__PURE__ */ Symbol("smrt-approvals.serviceWrite");
|
|
34
|
+
var TOKEN = Object.freeze({ capability: "smrt-approvals.serviceWrite" });
|
|
35
|
+
var minted = /* @__PURE__ */ new WeakSet();
|
|
36
|
+
function serviceWriteOption() {
|
|
37
|
+
return { [SERVICE_WRITE]: TOKEN };
|
|
38
|
+
}
|
|
39
|
+
function adoptServiceWrite(instance, options) {
|
|
40
|
+
if (options && typeof options === "object" && options[SERVICE_WRITE] === TOKEN) minted.add(instance);
|
|
41
|
+
}
|
|
42
|
+
function hasServiceWrite(instance) {
|
|
43
|
+
return minted.has(instance);
|
|
44
|
+
}
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/models/ApprovalRequest.ts
|
|
47
|
+
var __defProp$5 = Object.defineProperty;
|
|
48
|
+
var __getOwnPropDesc$5 = Object.getOwnPropertyDescriptor;
|
|
49
|
+
var __defNormalProp$5 = (obj, key, value) => key in obj ? __defProp$5(obj, key, {
|
|
50
|
+
enumerable: true,
|
|
51
|
+
configurable: true,
|
|
52
|
+
writable: true,
|
|
53
|
+
value
|
|
54
|
+
}) : obj[key] = value;
|
|
55
|
+
var __decorateClass$5 = (decorators, target, key, kind) => {
|
|
56
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$5(target, key) : target;
|
|
57
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
58
|
+
if (kind && result) __defProp$5(target, key, result);
|
|
59
|
+
return result;
|
|
60
|
+
};
|
|
61
|
+
var __publicField$5 = (obj, key, value) => __defNormalProp$5(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
62
|
+
var ApprovalRequest = class extends SmrtObject {
|
|
63
|
+
tenantId = "";
|
|
64
|
+
kind = "";
|
|
65
|
+
subjectType = "";
|
|
66
|
+
subjectId = "";
|
|
67
|
+
subjectRevisionHash = "";
|
|
68
|
+
requestKey = "";
|
|
69
|
+
requestedBy = "";
|
|
70
|
+
requestedByType = "human";
|
|
71
|
+
summary = "";
|
|
72
|
+
status = "pending";
|
|
73
|
+
requiredApprovals = 1;
|
|
74
|
+
approvalCount = 0;
|
|
75
|
+
requiredPermissions = "[]";
|
|
76
|
+
version = 0;
|
|
77
|
+
expiresAt = null;
|
|
78
|
+
decidedAt = null;
|
|
79
|
+
consumedAt = null;
|
|
80
|
+
consumedBy = "";
|
|
81
|
+
constructor(options = {}) {
|
|
82
|
+
super(options);
|
|
83
|
+
adoptServiceWrite(this, options);
|
|
84
|
+
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
85
|
+
if (options.kind !== void 0) this.kind = options.kind;
|
|
86
|
+
if (options.subjectType !== void 0) this.subjectType = options.subjectType;
|
|
87
|
+
if (options.subjectId !== void 0) this.subjectId = options.subjectId;
|
|
88
|
+
if (options.subjectRevisionHash !== void 0) this.subjectRevisionHash = options.subjectRevisionHash;
|
|
89
|
+
if (options.requestKey !== void 0) this.requestKey = options.requestKey;
|
|
90
|
+
if (options.requestedBy !== void 0) this.requestedBy = options.requestedBy;
|
|
91
|
+
if (options.requestedByType !== void 0) this.requestedByType = options.requestedByType;
|
|
92
|
+
if (options.summary !== void 0) this.summary = options.summary;
|
|
93
|
+
if (options.status !== void 0) this.status = options.status;
|
|
94
|
+
if (options.requiredApprovals !== void 0) this.requiredApprovals = options.requiredApprovals;
|
|
95
|
+
if (options.approvalCount !== void 0) this.approvalCount = options.approvalCount;
|
|
96
|
+
if (options.requiredPermissions !== void 0) this.requiredPermissions = options.requiredPermissions;
|
|
97
|
+
if (options.version !== void 0) this.version = options.version;
|
|
98
|
+
if (options.expiresAt !== void 0) this.expiresAt = options.expiresAt;
|
|
99
|
+
if (options.decidedAt !== void 0) this.decidedAt = options.decidedAt;
|
|
100
|
+
if (options.consumedAt !== void 0) this.consumedAt = options.consumedAt;
|
|
101
|
+
if (options.consumedBy !== void 0) this.consumedBy = options.consumedBy;
|
|
102
|
+
}
|
|
103
|
+
/** The permission slugs a decider must hold (never throws; bad JSON = none valid). */
|
|
104
|
+
getRequiredPermissions() {
|
|
105
|
+
try {
|
|
106
|
+
const parsed = JSON.parse(this.requiredPermissions || "[]");
|
|
107
|
+
return Array.isArray(parsed) ? parsed.filter((slug) => typeof slug === "string") : [];
|
|
108
|
+
} catch {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** Store the permission slugs a decider must hold. */
|
|
113
|
+
setRequiredPermissions(slugs) {
|
|
114
|
+
this.requiredPermissions = JSON.stringify([...slugs]);
|
|
115
|
+
}
|
|
116
|
+
/** The expiry as epoch milliseconds (`NaN` when unset or unparseable). */
|
|
117
|
+
expiresAtMs() {
|
|
118
|
+
const value = this.expiresAt;
|
|
119
|
+
if (value instanceof Date) return value.getTime();
|
|
120
|
+
if (typeof value === "string" || typeof value === "number") return new Date(value).getTime();
|
|
121
|
+
return NaN;
|
|
122
|
+
}
|
|
123
|
+
/** True when the approval was consumed. */
|
|
124
|
+
isConsumed() {
|
|
125
|
+
return this.consumedAt !== null && this.consumedAt !== void 0;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Refuse any insert `ApprovalService` did not mint (see
|
|
129
|
+
* `write-capability.ts`), and any minted insert that does not start in the
|
|
130
|
+
* initial state. Runs for ordinary saves and bulk creates alike (core
|
|
131
|
+
* wraps an error thrown here; `save()` checks first so callers see it).
|
|
132
|
+
*
|
|
133
|
+
* @throws {ApprovalError} `APPROVAL_FORBIDDEN`.
|
|
134
|
+
*/
|
|
135
|
+
async validateBeforeSave() {
|
|
136
|
+
await super.validateBeforeSave();
|
|
137
|
+
this.assertInsertAllowed();
|
|
138
|
+
}
|
|
139
|
+
assertInsertAllowed() {
|
|
140
|
+
if (this.isPersisted) return;
|
|
141
|
+
if (!hasServiceWrite(this)) throw new ApprovalError("APPROVAL_FORBIDDEN", "ApprovalRequest rows are created only by ApprovalService.requestApproval().");
|
|
142
|
+
if (this.status !== "pending" || Number(this.approvalCount) !== 0 || Number(this.version) !== 1 || this.decidedAt || this.consumedAt || this.consumedBy) throw new ApprovalError("APPROVAL_FORBIDDEN", "A new ApprovalRequest must start pending, undecided, and unconsumed.");
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Insert a new request. A loaded request is never re-saved: every later
|
|
146
|
+
* change is a guarded transition in `ApprovalService`.
|
|
147
|
+
*
|
|
148
|
+
* @throws {ApprovalError} `APPROVAL_FORBIDDEN` for a persisted request.
|
|
149
|
+
*/
|
|
150
|
+
async save() {
|
|
151
|
+
if (this.isPersisted) throw new ApprovalError("APPROVAL_FORBIDDEN", `ApprovalRequest ${this.id} is changed only through ApprovalService transitions.`);
|
|
152
|
+
this.assertInsertAllowed();
|
|
153
|
+
this.requireInsertOnSave();
|
|
154
|
+
return await super.save();
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Requests are durable records of a decision.
|
|
158
|
+
*
|
|
159
|
+
* @throws {ApprovalError} always.
|
|
160
|
+
*/
|
|
161
|
+
async delete() {
|
|
162
|
+
throw new ApprovalError("APPROVAL_FORBIDDEN", `ApprovalRequest ${this.id} cannot be deleted.`);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
__publicField$5(ApprovalRequest, "__smrtPackage__", "@happyvertical/smrt-approvals");
|
|
166
|
+
__decorateClass$5([tenantId()], ApprovalRequest.prototype, "tenantId", 2);
|
|
167
|
+
__decorateClass$5([field({
|
|
168
|
+
type: "text",
|
|
169
|
+
required: true,
|
|
170
|
+
readonly: true
|
|
171
|
+
})], ApprovalRequest.prototype, "kind", 2);
|
|
172
|
+
__decorateClass$5([field({
|
|
173
|
+
type: "text",
|
|
174
|
+
required: true,
|
|
175
|
+
readonly: true
|
|
176
|
+
})], ApprovalRequest.prototype, "subjectType", 2);
|
|
177
|
+
__decorateClass$5([field({
|
|
178
|
+
type: "text",
|
|
179
|
+
required: true,
|
|
180
|
+
readonly: true
|
|
181
|
+
})], ApprovalRequest.prototype, "subjectId", 2);
|
|
182
|
+
__decorateClass$5([field({
|
|
183
|
+
type: "text",
|
|
184
|
+
required: true,
|
|
185
|
+
readonly: true
|
|
186
|
+
})], ApprovalRequest.prototype, "subjectRevisionHash", 2);
|
|
187
|
+
__decorateClass$5([field({
|
|
188
|
+
type: "text",
|
|
189
|
+
required: true,
|
|
190
|
+
readonly: true
|
|
191
|
+
})], ApprovalRequest.prototype, "requestKey", 2);
|
|
192
|
+
__decorateClass$5([field({
|
|
193
|
+
type: "text",
|
|
194
|
+
required: true,
|
|
195
|
+
readonly: true
|
|
196
|
+
})], ApprovalRequest.prototype, "requestedBy", 2);
|
|
197
|
+
__decorateClass$5([field({
|
|
198
|
+
type: "text",
|
|
199
|
+
readonly: true
|
|
200
|
+
})], ApprovalRequest.prototype, "requestedByType", 2);
|
|
201
|
+
__decorateClass$5([field({
|
|
202
|
+
type: "text",
|
|
203
|
+
readonly: true
|
|
204
|
+
})], ApprovalRequest.prototype, "summary", 2);
|
|
205
|
+
__decorateClass$5([field({
|
|
206
|
+
type: "text",
|
|
207
|
+
readonly: true
|
|
208
|
+
})], ApprovalRequest.prototype, "status", 2);
|
|
209
|
+
__decorateClass$5([field({
|
|
210
|
+
type: "integer",
|
|
211
|
+
readonly: true
|
|
212
|
+
})], ApprovalRequest.prototype, "requiredApprovals", 2);
|
|
213
|
+
__decorateClass$5([field({
|
|
214
|
+
type: "integer",
|
|
215
|
+
readonly: true
|
|
216
|
+
})], ApprovalRequest.prototype, "approvalCount", 2);
|
|
217
|
+
__decorateClass$5([field({
|
|
218
|
+
type: "text",
|
|
219
|
+
readonly: true
|
|
220
|
+
})], ApprovalRequest.prototype, "requiredPermissions", 2);
|
|
221
|
+
__decorateClass$5([field({
|
|
222
|
+
type: "integer",
|
|
223
|
+
readonly: true
|
|
224
|
+
})], ApprovalRequest.prototype, "version", 2);
|
|
225
|
+
__decorateClass$5([field({
|
|
226
|
+
type: "datetime",
|
|
227
|
+
readonly: true
|
|
228
|
+
})], ApprovalRequest.prototype, "expiresAt", 2);
|
|
229
|
+
__decorateClass$5([field({
|
|
230
|
+
type: "datetime",
|
|
231
|
+
readonly: true
|
|
232
|
+
})], ApprovalRequest.prototype, "decidedAt", 2);
|
|
233
|
+
__decorateClass$5([field({
|
|
234
|
+
type: "datetime",
|
|
235
|
+
readonly: true
|
|
236
|
+
})], ApprovalRequest.prototype, "consumedAt", 2);
|
|
237
|
+
__decorateClass$5([field({
|
|
238
|
+
type: "text",
|
|
239
|
+
readonly: true
|
|
240
|
+
})], ApprovalRequest.prototype, "consumedBy", 2);
|
|
241
|
+
ApprovalRequest = __decorateClass$5([TenantScoped({ mode: "required" }), smrt({
|
|
242
|
+
tableName: "approval_requests",
|
|
243
|
+
api: { include: ["list", "get"] },
|
|
244
|
+
mcp: { include: ["list", "get"] },
|
|
245
|
+
cli: false,
|
|
246
|
+
indexes: [
|
|
247
|
+
{
|
|
248
|
+
name: "approval_requests_tenant_request_key",
|
|
249
|
+
columns: ["tenantId", "requestKey"],
|
|
250
|
+
unique: true
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
name: "approval_requests_status_expiry_idx",
|
|
254
|
+
columns: [
|
|
255
|
+
"tenantId",
|
|
256
|
+
"status",
|
|
257
|
+
"expiresAt"
|
|
258
|
+
]
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
name: "approval_requests_subject_idx",
|
|
262
|
+
columns: [
|
|
263
|
+
"tenantId",
|
|
264
|
+
"subjectType",
|
|
265
|
+
"subjectId"
|
|
266
|
+
]
|
|
267
|
+
}
|
|
268
|
+
]
|
|
269
|
+
})], ApprovalRequest);
|
|
270
|
+
//#endregion
|
|
271
|
+
//#region src/models/ApprovalEvent.ts
|
|
272
|
+
var __defProp$4 = Object.defineProperty;
|
|
273
|
+
var __getOwnPropDesc$4 = Object.getOwnPropertyDescriptor;
|
|
274
|
+
var __defNormalProp$4 = (obj, key, value) => key in obj ? __defProp$4(obj, key, {
|
|
275
|
+
enumerable: true,
|
|
276
|
+
configurable: true,
|
|
277
|
+
writable: true,
|
|
278
|
+
value
|
|
279
|
+
}) : obj[key] = value;
|
|
280
|
+
var __decorateClass$4 = (decorators, target, key, kind) => {
|
|
281
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$4(target, key) : target;
|
|
282
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
283
|
+
if (kind && result) __defProp$4(target, key, result);
|
|
284
|
+
return result;
|
|
285
|
+
};
|
|
286
|
+
var __publicField$4 = (obj, key, value) => __defNormalProp$4(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
287
|
+
var ApprovalEvent = class extends SmrtObject {
|
|
288
|
+
tenantId = "";
|
|
289
|
+
requestId = "";
|
|
290
|
+
type = "created";
|
|
291
|
+
actorId = "";
|
|
292
|
+
actorType = "human";
|
|
293
|
+
reason = "";
|
|
294
|
+
subjectRevisionHash = "";
|
|
295
|
+
sequence = 0;
|
|
296
|
+
voteKey = null;
|
|
297
|
+
occurredAt = null;
|
|
298
|
+
constructor(options = {}) {
|
|
299
|
+
super(options);
|
|
300
|
+
adoptServiceWrite(this, options);
|
|
301
|
+
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
302
|
+
if (options.requestId !== void 0) this.requestId = options.requestId;
|
|
303
|
+
if (options.type !== void 0) this.type = options.type;
|
|
304
|
+
if (options.actorId !== void 0) this.actorId = options.actorId;
|
|
305
|
+
if (options.actorType !== void 0) this.actorType = options.actorType;
|
|
306
|
+
if (options.reason !== void 0) this.reason = options.reason;
|
|
307
|
+
if (options.subjectRevisionHash !== void 0) this.subjectRevisionHash = options.subjectRevisionHash;
|
|
308
|
+
if (options.sequence !== void 0) this.sequence = options.sequence;
|
|
309
|
+
if (options.voteKey !== void 0) this.voteKey = options.voteKey;
|
|
310
|
+
if (options.occurredAt !== void 0) this.occurredAt = options.occurredAt;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Append the event. An event is written once and never updated.
|
|
314
|
+
*
|
|
315
|
+
* @throws {ApprovalError} `APPROVAL_FORBIDDEN` for a persisted event.
|
|
316
|
+
*/
|
|
317
|
+
async save() {
|
|
318
|
+
if (this.isPersisted) throw new ApprovalError("APPROVAL_FORBIDDEN", `ApprovalEvent ${this.id} is append-only and cannot be updated.`);
|
|
319
|
+
this.assertInsertAllowed();
|
|
320
|
+
if (!this.occurredAt) this.occurredAt = /* @__PURE__ */ new Date();
|
|
321
|
+
this.requireInsertOnSave();
|
|
322
|
+
return await super.save();
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Refuse any insert `ApprovalService` did not mint in the same transaction
|
|
326
|
+
* as its guarded UPDATE (see `write-capability.ts`): a forged `approved`
|
|
327
|
+
* event would otherwise count toward quorum. Runs for ordinary saves and
|
|
328
|
+
* bulk creates alike.
|
|
329
|
+
*
|
|
330
|
+
* @throws {ApprovalError} `APPROVAL_FORBIDDEN`.
|
|
331
|
+
*/
|
|
332
|
+
async validateBeforeSave() {
|
|
333
|
+
await super.validateBeforeSave();
|
|
334
|
+
this.assertInsertAllowed();
|
|
335
|
+
}
|
|
336
|
+
assertInsertAllowed() {
|
|
337
|
+
if (this.isPersisted || hasServiceWrite(this)) return;
|
|
338
|
+
throw new ApprovalError("APPROVAL_FORBIDDEN", "ApprovalEvent rows are appended only by ApprovalService transitions.");
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Events are append-only.
|
|
342
|
+
*
|
|
343
|
+
* @throws {ApprovalError} always.
|
|
344
|
+
*/
|
|
345
|
+
async delete() {
|
|
346
|
+
throw new ApprovalError("APPROVAL_FORBIDDEN", `ApprovalEvent ${this.id} is append-only and cannot be deleted.`);
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
__publicField$4(ApprovalEvent, "__smrtPackage__", "@happyvertical/smrt-approvals");
|
|
350
|
+
__decorateClass$4([tenantId()], ApprovalEvent.prototype, "tenantId", 2);
|
|
351
|
+
__decorateClass$4([foreignKey(ApprovalRequest, {
|
|
352
|
+
required: true,
|
|
353
|
+
readonly: true,
|
|
354
|
+
onDelete: "RESTRICT",
|
|
355
|
+
onUpdate: "RESTRICT",
|
|
356
|
+
constraint: { engines: ["postgres", "sqlite"] }
|
|
357
|
+
})], ApprovalEvent.prototype, "requestId", 2);
|
|
358
|
+
__decorateClass$4([field({
|
|
359
|
+
type: "text",
|
|
360
|
+
required: true,
|
|
361
|
+
readonly: true
|
|
362
|
+
})], ApprovalEvent.prototype, "type", 2);
|
|
363
|
+
__decorateClass$4([field({
|
|
364
|
+
type: "text",
|
|
365
|
+
required: true,
|
|
366
|
+
readonly: true
|
|
367
|
+
})], ApprovalEvent.prototype, "actorId", 2);
|
|
368
|
+
__decorateClass$4([field({
|
|
369
|
+
type: "text",
|
|
370
|
+
readonly: true
|
|
371
|
+
})], ApprovalEvent.prototype, "actorType", 2);
|
|
372
|
+
__decorateClass$4([field({
|
|
373
|
+
type: "text",
|
|
374
|
+
readonly: true
|
|
375
|
+
})], ApprovalEvent.prototype, "reason", 2);
|
|
376
|
+
__decorateClass$4([field({
|
|
377
|
+
type: "text",
|
|
378
|
+
readonly: true
|
|
379
|
+
})], ApprovalEvent.prototype, "subjectRevisionHash", 2);
|
|
380
|
+
__decorateClass$4([field({
|
|
381
|
+
type: "integer",
|
|
382
|
+
readonly: true
|
|
383
|
+
})], ApprovalEvent.prototype, "sequence", 2);
|
|
384
|
+
__decorateClass$4([field({
|
|
385
|
+
type: "text",
|
|
386
|
+
nullable: true,
|
|
387
|
+
readonly: true
|
|
388
|
+
})], ApprovalEvent.prototype, "voteKey", 2);
|
|
389
|
+
__decorateClass$4([field({
|
|
390
|
+
type: "datetime",
|
|
391
|
+
readonly: true
|
|
392
|
+
})], ApprovalEvent.prototype, "occurredAt", 2);
|
|
393
|
+
ApprovalEvent = __decorateClass$4([TenantScoped({ mode: "required" }), smrt({
|
|
394
|
+
tableName: "approval_events",
|
|
395
|
+
api: { include: ["list", "get"] },
|
|
396
|
+
mcp: { include: ["list", "get"] },
|
|
397
|
+
cli: false,
|
|
398
|
+
indexes: [
|
|
399
|
+
{
|
|
400
|
+
name: "approval_events_request_sequence_key",
|
|
401
|
+
columns: ["requestId", "sequence"],
|
|
402
|
+
unique: true
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
name: "approval_events_request_vote_key",
|
|
406
|
+
columns: ["requestId", "voteKey"],
|
|
407
|
+
unique: true
|
|
408
|
+
},
|
|
409
|
+
{
|
|
410
|
+
name: "approval_events_tenant_occurred_idx",
|
|
411
|
+
columns: ["tenantId", "occurredAt"]
|
|
412
|
+
}
|
|
413
|
+
]
|
|
414
|
+
})], ApprovalEvent);
|
|
415
|
+
//#endregion
|
|
416
|
+
//#region src/collections/ApprovalEventCollection.ts
|
|
417
|
+
var __defProp$3 = Object.defineProperty;
|
|
418
|
+
var __getOwnPropDesc$3 = Object.getOwnPropertyDescriptor;
|
|
419
|
+
var __defNormalProp$3 = (obj, key, value) => key in obj ? __defProp$3(obj, key, {
|
|
420
|
+
enumerable: true,
|
|
421
|
+
configurable: true,
|
|
422
|
+
writable: true,
|
|
423
|
+
value
|
|
424
|
+
}) : obj[key] = value;
|
|
425
|
+
var __decorateClass$3 = (decorators, target, key, kind) => {
|
|
426
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$3(target, key) : target;
|
|
427
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
428
|
+
if (kind && result) __defProp$3(target, key, result);
|
|
429
|
+
return result;
|
|
430
|
+
};
|
|
431
|
+
var __publicField$3 = (obj, key, value) => __defNormalProp$3(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
432
|
+
var ApprovalEventCollection = class extends SmrtCollection {};
|
|
433
|
+
__publicField$3(ApprovalEventCollection, "__smrtPackage__", "@happyvertical/smrt-approvals");
|
|
434
|
+
__publicField$3(ApprovalEventCollection, "_itemClass", ApprovalEvent);
|
|
435
|
+
ApprovalEventCollection = __decorateClass$3([smrt({
|
|
436
|
+
api: { include: ["list", "get"] },
|
|
437
|
+
mcp: { include: ["list", "get"] },
|
|
438
|
+
cli: false
|
|
439
|
+
})], ApprovalEventCollection);
|
|
440
|
+
//#endregion
|
|
441
|
+
//#region src/kinds.ts
|
|
442
|
+
var MAX_REQUIRED_APPROVALS = 25;
|
|
443
|
+
var DEFAULT_APPROVAL_TTL_MS = 10080 * 60 * 1e3;
|
|
444
|
+
var MAX_APPROVAL_TTL_MS = 366 * 24 * 60 * 60 * 1e3;
|
|
445
|
+
var MIN_APPROVAL_TTL_MS = 60 * 1e3;
|
|
446
|
+
var KIND_KEY = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9_-]*)+$/;
|
|
447
|
+
var QUALIFIED_CLASS = /^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*:[A-Za-z_$][\w$]*$/;
|
|
448
|
+
var PERMISSION_SLUG = /^[a-z][a-z0-9_-]*(?:\.[a-z0-9][a-z0-9_-]*)+$/;
|
|
449
|
+
var kinds = /* @__PURE__ */ new Map();
|
|
450
|
+
function isPermissionSlug(value) {
|
|
451
|
+
return typeof value === "string" && PERMISSION_SLUG.test(value);
|
|
452
|
+
}
|
|
453
|
+
function assertQuorum(value, label) {
|
|
454
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > 25) throw new ApprovalError("APPROVAL_INVALID", `${label} must be an integer from 1 to 25, got ${String(value)}.`);
|
|
455
|
+
}
|
|
456
|
+
function assertTtl(value, label) {
|
|
457
|
+
if (!Number.isSafeInteger(value) || value < 6e4 || value > 316224e5) throw new ApprovalError("APPROVAL_INVALID", `${label} must be an integer number of milliseconds from ${MIN_APPROVAL_TTL_MS} to ${MAX_APPROVAL_TTL_MS}, got ${String(value)}.`);
|
|
458
|
+
}
|
|
459
|
+
function defineApprovalKind(input) {
|
|
460
|
+
if (!KIND_KEY.test(input.key ?? "")) throw new ApprovalError("APPROVAL_INVALID", `Approval kind key must be dotted lower case such as 'social.post.publish', got '${String(input.key)}'.`);
|
|
461
|
+
if (!QUALIFIED_CLASS.test(input.subject ?? "")) throw new ApprovalError("APPROVAL_INVALID", `Approval kind '${input.key}' subject must be a qualified class name such as '@happyvertical/smrt-social:SocialPost', got '${String(input.subject)}'.`);
|
|
462
|
+
if (!isPermissionSlug(input.permission)) throw new ApprovalError("APPROVAL_INVALID", `Approval kind '${input.key}' permission must be a slug such as 'social.approve-post', got '${String(input.permission)}'.`);
|
|
463
|
+
const requiredApprovals = input.defaults?.requiredApprovals ?? 1;
|
|
464
|
+
const ttlMs = input.defaults?.ttlMs ?? 6048e5;
|
|
465
|
+
assertQuorum(requiredApprovals, `Approval kind '${input.key}' requiredApprovals`);
|
|
466
|
+
assertTtl(ttlMs, `Approval kind '${input.key}' ttlMs`);
|
|
467
|
+
const kind = Object.freeze({
|
|
468
|
+
key: input.key,
|
|
469
|
+
subject: input.subject,
|
|
470
|
+
permission: input.permission,
|
|
471
|
+
defaults: Object.freeze({
|
|
472
|
+
requiredApprovals,
|
|
473
|
+
ttlMs
|
|
474
|
+
})
|
|
475
|
+
});
|
|
476
|
+
const existing = kinds.get(kind.key);
|
|
477
|
+
if (existing) {
|
|
478
|
+
if (existing.subject === kind.subject && existing.permission === kind.permission && existing.defaults.requiredApprovals === requiredApprovals && existing.defaults.ttlMs === ttlMs) return existing;
|
|
479
|
+
throw new ApprovalError("APPROVAL_KIND_CONFLICT", `Approval kind '${kind.key}' is already defined with different rules.`);
|
|
480
|
+
}
|
|
481
|
+
kinds.set(kind.key, kind);
|
|
482
|
+
return kind;
|
|
483
|
+
}
|
|
484
|
+
function getApprovalKind(key) {
|
|
485
|
+
return kinds.get(key);
|
|
486
|
+
}
|
|
487
|
+
function listApprovalKinds() {
|
|
488
|
+
return [...kinds.values()].sort((a, b) => a.key.localeCompare(b.key));
|
|
489
|
+
}
|
|
490
|
+
function requireApprovalKind(key) {
|
|
491
|
+
const kind = kinds.get(key);
|
|
492
|
+
if (!kind) throw new ApprovalError("APPROVAL_KIND_UNKNOWN", `Approval kind '${key}' is not defined in this process. Import the package that calls defineApprovalKind() for it.`);
|
|
493
|
+
return kind;
|
|
494
|
+
}
|
|
495
|
+
function unregisterApprovalKind(key) {
|
|
496
|
+
return kinds.delete(key);
|
|
497
|
+
}
|
|
498
|
+
function assertPolicyTightens(kind, rules) {
|
|
499
|
+
const quorum = rules.requiredApprovals ?? 0;
|
|
500
|
+
if (quorum !== 0) {
|
|
501
|
+
assertQuorum(quorum, `Approval policy for '${kind.key}' requiredApprovals`);
|
|
502
|
+
if (quorum < kind.defaults.requiredApprovals) throw new ApprovalError("APPROVAL_POLICY_LOOSENS", `Approval policy for '${kind.key}' may only raise requiredApprovals (default ${kind.defaults.requiredApprovals}), got ${quorum}.`);
|
|
503
|
+
}
|
|
504
|
+
const ttl = rules.ttlMs ?? 0;
|
|
505
|
+
if (ttl !== 0) {
|
|
506
|
+
assertTtl(ttl, `Approval policy for '${kind.key}' ttlMs`);
|
|
507
|
+
if (ttl > kind.defaults.ttlMs) throw new ApprovalError("APPROVAL_POLICY_LOOSENS", `Approval policy for '${kind.key}' may only shorten ttlMs (default ${kind.defaults.ttlMs}), got ${ttl}.`);
|
|
508
|
+
}
|
|
509
|
+
const permission = rules.requiredPermission ?? "";
|
|
510
|
+
if (permission !== "" && !isPermissionSlug(permission)) throw new ApprovalError("APPROVAL_INVALID", `Approval policy for '${kind.key}' requiredPermission must be a permission slug, got '${permission}'.`);
|
|
511
|
+
}
|
|
512
|
+
function effectiveRules(kind, policy) {
|
|
513
|
+
const policyQuorum = Number(policy?.requiredApprovals ?? 0);
|
|
514
|
+
const policyTtl = Number(policy?.ttlMs ?? 0);
|
|
515
|
+
const requiredApprovals = Number.isSafeInteger(policyQuorum) && policyQuorum > 0 ? Math.min(Math.max(kind.defaults.requiredApprovals, policyQuorum), 25) : kind.defaults.requiredApprovals;
|
|
516
|
+
const ttlMs = Number.isSafeInteger(policyTtl) && policyTtl > 0 ? Math.max(Math.min(kind.defaults.ttlMs, policyTtl), MIN_APPROVAL_TTL_MS) : kind.defaults.ttlMs;
|
|
517
|
+
const requiredPermissions = [kind.permission];
|
|
518
|
+
const extra = String(policy?.requiredPermission ?? "").trim();
|
|
519
|
+
if (extra && !requiredPermissions.includes(extra)) requiredPermissions.push(extra);
|
|
520
|
+
return {
|
|
521
|
+
requiredApprovals,
|
|
522
|
+
ttlMs,
|
|
523
|
+
requiredPermissions
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
//#endregion
|
|
527
|
+
//#region src/models/ApprovalPolicy.ts
|
|
528
|
+
var __defProp$2 = Object.defineProperty;
|
|
529
|
+
var __getOwnPropDesc$2 = Object.getOwnPropertyDescriptor;
|
|
530
|
+
var __defNormalProp$2 = (obj, key, value) => key in obj ? __defProp$2(obj, key, {
|
|
531
|
+
enumerable: true,
|
|
532
|
+
configurable: true,
|
|
533
|
+
writable: true,
|
|
534
|
+
value
|
|
535
|
+
}) : obj[key] = value;
|
|
536
|
+
var __decorateClass$2 = (decorators, target, key, kind) => {
|
|
537
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$2(target, key) : target;
|
|
538
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
539
|
+
if (kind && result) __defProp$2(target, key, result);
|
|
540
|
+
return result;
|
|
541
|
+
};
|
|
542
|
+
var __publicField$2 = (obj, key, value) => __defNormalProp$2(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
543
|
+
var ApprovalPolicy = class extends SmrtObject {
|
|
544
|
+
tenantId = "";
|
|
545
|
+
kind = "";
|
|
546
|
+
requiredApprovals = 0;
|
|
547
|
+
ttlMs = 0;
|
|
548
|
+
requiredPermission = "";
|
|
549
|
+
updatedBy = "";
|
|
550
|
+
constructor(options = {}) {
|
|
551
|
+
super(options);
|
|
552
|
+
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
553
|
+
if (options.kind !== void 0) this.kind = options.kind;
|
|
554
|
+
if (options.requiredApprovals !== void 0) this.requiredApprovals = options.requiredApprovals;
|
|
555
|
+
if (options.ttlMs !== void 0) this.ttlMs = options.ttlMs;
|
|
556
|
+
if (options.requiredPermission !== void 0) this.requiredPermission = options.requiredPermission;
|
|
557
|
+
if (options.updatedBy !== void 0) this.updatedBy = options.updatedBy;
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Validate against the registered kind, then persist. A row for an unknown
|
|
561
|
+
* kind, or one that would loosen the kind, is refused.
|
|
562
|
+
*
|
|
563
|
+
* @throws {ApprovalError} `APPROVAL_KIND_UNKNOWN`,
|
|
564
|
+
* `APPROVAL_POLICY_LOOSENS`, or `APPROVAL_INVALID`.
|
|
565
|
+
*/
|
|
566
|
+
async save() {
|
|
567
|
+
this.requiredPermission = String(this.requiredPermission ?? "").trim();
|
|
568
|
+
this.requiredApprovals = Number(this.requiredApprovals ?? 0);
|
|
569
|
+
this.ttlMs = Number(this.ttlMs ?? 0);
|
|
570
|
+
assertPolicyTightens(requireApprovalKind(this.kind), {
|
|
571
|
+
requiredApprovals: this.requiredApprovals,
|
|
572
|
+
ttlMs: this.ttlMs,
|
|
573
|
+
requiredPermission: this.requiredPermission
|
|
574
|
+
});
|
|
575
|
+
return await super.save();
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
__publicField$2(ApprovalPolicy, "__smrtPackage__", "@happyvertical/smrt-approvals");
|
|
579
|
+
__decorateClass$2([tenantId()], ApprovalPolicy.prototype, "tenantId", 2);
|
|
580
|
+
__decorateClass$2([field({
|
|
581
|
+
type: "text",
|
|
582
|
+
required: true
|
|
583
|
+
})], ApprovalPolicy.prototype, "kind", 2);
|
|
584
|
+
__decorateClass$2([field({ type: "integer" })], ApprovalPolicy.prototype, "requiredApprovals", 2);
|
|
585
|
+
__decorateClass$2([field({ type: "integer" })], ApprovalPolicy.prototype, "ttlMs", 2);
|
|
586
|
+
__decorateClass$2([field({ type: "text" })], ApprovalPolicy.prototype, "requiredPermission", 2);
|
|
587
|
+
__decorateClass$2([field({ type: "text" })], ApprovalPolicy.prototype, "updatedBy", 2);
|
|
588
|
+
ApprovalPolicy = __decorateClass$2([TenantScoped({ mode: "required" }), smrt({
|
|
589
|
+
tableName: "approval_policies",
|
|
590
|
+
api: { include: ["list", "get"] },
|
|
591
|
+
mcp: { include: ["list", "get"] },
|
|
592
|
+
cli: false,
|
|
593
|
+
indexes: [{
|
|
594
|
+
name: "approval_policies_tenant_kind_key",
|
|
595
|
+
columns: ["tenantId", "kind"],
|
|
596
|
+
unique: true
|
|
597
|
+
}]
|
|
598
|
+
})], ApprovalPolicy);
|
|
599
|
+
//#endregion
|
|
600
|
+
//#region src/collections/ApprovalPolicyCollection.ts
|
|
601
|
+
var __defProp$1 = Object.defineProperty;
|
|
602
|
+
var __getOwnPropDesc$1 = Object.getOwnPropertyDescriptor;
|
|
603
|
+
var __defNormalProp$1 = (obj, key, value) => key in obj ? __defProp$1(obj, key, {
|
|
604
|
+
enumerable: true,
|
|
605
|
+
configurable: true,
|
|
606
|
+
writable: true,
|
|
607
|
+
value
|
|
608
|
+
}) : obj[key] = value;
|
|
609
|
+
var __decorateClass$1 = (decorators, target, key, kind) => {
|
|
610
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$1(target, key) : target;
|
|
611
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
612
|
+
if (kind && result) __defProp$1(target, key, result);
|
|
613
|
+
return result;
|
|
614
|
+
};
|
|
615
|
+
var __publicField$1 = (obj, key, value) => __defNormalProp$1(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
616
|
+
var ApprovalPolicyCollection = class extends SmrtCollection {};
|
|
617
|
+
__publicField$1(ApprovalPolicyCollection, "__smrtPackage__", "@happyvertical/smrt-approvals");
|
|
618
|
+
__publicField$1(ApprovalPolicyCollection, "_itemClass", ApprovalPolicy);
|
|
619
|
+
ApprovalPolicyCollection = __decorateClass$1([smrt({
|
|
620
|
+
api: { include: ["list", "get"] },
|
|
621
|
+
mcp: { include: ["list", "get"] },
|
|
622
|
+
cli: false
|
|
623
|
+
})], ApprovalPolicyCollection);
|
|
624
|
+
//#endregion
|
|
625
|
+
//#region src/collections/ApprovalRequestCollection.ts
|
|
626
|
+
var __defProp = Object.defineProperty;
|
|
627
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
628
|
+
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, {
|
|
629
|
+
enumerable: true,
|
|
630
|
+
configurable: true,
|
|
631
|
+
writable: true,
|
|
632
|
+
value
|
|
633
|
+
}) : obj[key] = value;
|
|
634
|
+
var __decorateClass = (decorators, target, key, kind) => {
|
|
635
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
|
|
636
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
637
|
+
if (kind && result) __defProp(target, key, result);
|
|
638
|
+
return result;
|
|
639
|
+
};
|
|
640
|
+
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
|
|
641
|
+
var ApprovalRequestCollection = class extends SmrtCollection {};
|
|
642
|
+
__publicField(ApprovalRequestCollection, "__smrtPackage__", "@happyvertical/smrt-approvals");
|
|
643
|
+
__publicField(ApprovalRequestCollection, "_itemClass", ApprovalRequest);
|
|
644
|
+
ApprovalRequestCollection = __decorateClass([smrt({
|
|
645
|
+
api: { include: ["list", "get"] },
|
|
646
|
+
mcp: { include: ["list", "get"] },
|
|
647
|
+
cli: false
|
|
648
|
+
})], ApprovalRequestCollection);
|
|
649
|
+
//#endregion
|
|
650
|
+
//#region src/service.ts
|
|
651
|
+
var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
652
|
+
var MAX_TOKEN_LENGTH = 256;
|
|
653
|
+
var MAX_TEXT_LENGTH = 4e3;
|
|
654
|
+
var DEFAULT_MAX_ATTEMPTS = 8;
|
|
655
|
+
var REQUESTS_TABLE = "approval_requests";
|
|
656
|
+
var DECISION_EVENT = {
|
|
657
|
+
approve: "approved",
|
|
658
|
+
reject: "rejected",
|
|
659
|
+
request_changes: "changes_requested"
|
|
660
|
+
};
|
|
661
|
+
function refusal(reason, detail, id = null) {
|
|
662
|
+
return {
|
|
663
|
+
kind: "refused",
|
|
664
|
+
reason,
|
|
665
|
+
detail,
|
|
666
|
+
id
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
function isoNow(date) {
|
|
670
|
+
return date.toISOString();
|
|
671
|
+
}
|
|
672
|
+
function assertToken(value, label) {
|
|
673
|
+
const text = typeof value === "string" ? value.trim() : "";
|
|
674
|
+
if (!text || text.length > MAX_TOKEN_LENGTH) throw new ApprovalError("APPROVAL_INVALID", `${label} must be a non-empty string of at most ${MAX_TOKEN_LENGTH} characters.`);
|
|
675
|
+
return text;
|
|
676
|
+
}
|
|
677
|
+
function assertText(value, label) {
|
|
678
|
+
if (value === void 0 || value === null) return "";
|
|
679
|
+
if (typeof value !== "string" || value.length > MAX_TEXT_LENGTH) throw new ApprovalError("APPROVAL_INVALID", `${label} must be a string of at most ${MAX_TEXT_LENGTH} characters.`);
|
|
680
|
+
return value.trim();
|
|
681
|
+
}
|
|
682
|
+
function assertPrincipal(principal) {
|
|
683
|
+
if (!principal || typeof principal.can !== "function") throw new ApprovalError("APPROVAL_INVALID", "An approval principal must provide can(slug).");
|
|
684
|
+
assertToken(principal.id, "Approval principal id");
|
|
685
|
+
if (typeof principal.tenantId !== "string" || !UUID_RE.test(principal.tenantId)) throw new ApprovalError("APPROVAL_INVALID", "An approval principal must carry the UUID of the tenant its permissions were resolved for.");
|
|
686
|
+
if (!APPROVAL_PRINCIPAL_TYPES.includes(principal.type)) throw new ApprovalError("APPROVAL_INVALID", `Approval principal type must be one of ${APPROVAL_PRINCIPAL_TYPES.join(", ")}.`);
|
|
687
|
+
}
|
|
688
|
+
function countedApprovers(approvals, request) {
|
|
689
|
+
return new Set(approvals.filter((event) => event.type === "approved" && event.actorType === "human" && event.actorId !== request.requestedBy).map((event) => event.actorId));
|
|
690
|
+
}
|
|
691
|
+
function rowVersion(rows) {
|
|
692
|
+
const row = rows?.[0];
|
|
693
|
+
if (!row) return null;
|
|
694
|
+
const version = Number(row.version);
|
|
695
|
+
return Number.isSafeInteger(version) ? version : null;
|
|
696
|
+
}
|
|
697
|
+
var ApprovalService = class ApprovalService {
|
|
698
|
+
db;
|
|
699
|
+
clock;
|
|
700
|
+
maxAttempts;
|
|
701
|
+
constructor(options) {
|
|
702
|
+
if (!options?.db || typeof options.db.query !== "function") throw new ApprovalError("APPROVAL_INVALID", "ApprovalService requires an initialized database.");
|
|
703
|
+
if (typeof options.db.transaction !== "function") throw new ApprovalError("APPROVAL_NO_TRANSACTION", "ApprovalService requires a database adapter with transaction support.");
|
|
704
|
+
this.db = options.db;
|
|
705
|
+
this.clock = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
706
|
+
this.maxAttempts = Math.max(1, options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
|
|
707
|
+
}
|
|
708
|
+
/** One request in the principal's tenant, or `null`. */
|
|
709
|
+
async getRequest(principal, requestId) {
|
|
710
|
+
assertPrincipal(principal);
|
|
711
|
+
if (!UUID_RE.test(String(requestId ?? ""))) return null;
|
|
712
|
+
return await this.inTenant(principal, () => this.loadRequest(this.db, principal.tenantId, requestId));
|
|
713
|
+
}
|
|
714
|
+
/** A request's events in order, in the principal's tenant. */
|
|
715
|
+
async listEvents(principal, requestId) {
|
|
716
|
+
assertPrincipal(principal);
|
|
717
|
+
if (!UUID_RE.test(String(requestId ?? ""))) return [];
|
|
718
|
+
return await this.inTenant(principal, async () => {
|
|
719
|
+
return await (await ApprovalEventCollection.create({ db: this.db })).list({
|
|
720
|
+
where: {
|
|
721
|
+
tenantId: principal.tenantId,
|
|
722
|
+
requestId
|
|
723
|
+
},
|
|
724
|
+
orderBy: "sequence ASC",
|
|
725
|
+
limit: 1e3
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Open a request for approval of one subject revision. Any principal type
|
|
731
|
+
* may request. The quorum, expiry, and required permissions are fixed now
|
|
732
|
+
* from the kind's defaults tightened by the tenant's policy.
|
|
733
|
+
*/
|
|
734
|
+
async requestApproval(principal, input) {
|
|
735
|
+
assertPrincipal(principal);
|
|
736
|
+
const kind = requireApprovalKind(typeof input.kind === "string" ? input.kind : input.kind?.key);
|
|
737
|
+
const subjectId = assertToken(input.subjectId, "subjectId");
|
|
738
|
+
const subjectRevisionHash = assertToken(input.subjectRevisionHash, "subjectRevisionHash");
|
|
739
|
+
const requestKey = input.requestKey === void 0 ? globalThis.crypto.randomUUID() : assertToken(input.requestKey, "requestKey");
|
|
740
|
+
const summary = assertText(input.summary, "summary");
|
|
741
|
+
const tenantId = principal.tenantId;
|
|
742
|
+
const sameContent = (row) => row.kind === kind.key && row.subjectType === kind.subject && row.subjectId === subjectId && row.subjectRevisionHash === subjectRevisionHash;
|
|
743
|
+
if (this.contextMismatch(principal)) return ApprovalService.mismatchResult();
|
|
744
|
+
return await this.inTenant(principal, async () => {
|
|
745
|
+
let outcome;
|
|
746
|
+
try {
|
|
747
|
+
outcome = await this.runTransaction(async (tx) => {
|
|
748
|
+
const requests = await this.txCollection(ApprovalRequestCollection, tx);
|
|
749
|
+
const [existing] = await requests.list({
|
|
750
|
+
where: {
|
|
751
|
+
tenantId,
|
|
752
|
+
requestKey
|
|
753
|
+
},
|
|
754
|
+
limit: 1
|
|
755
|
+
});
|
|
756
|
+
if (existing) return sameContent(existing) ? {
|
|
757
|
+
kind: "done",
|
|
758
|
+
outcome: "already_applied",
|
|
759
|
+
id: String(existing.id)
|
|
760
|
+
} : refusal("request_key_conflict", `requestKey '${requestKey}' already names a different request`);
|
|
761
|
+
const policy = await this.loadPolicy(tx, tenantId, kind.key);
|
|
762
|
+
const rules = effectiveRules(kind, policy);
|
|
763
|
+
const now = this.clock();
|
|
764
|
+
const request = await requests.create({
|
|
765
|
+
tenantId,
|
|
766
|
+
kind: kind.key,
|
|
767
|
+
subjectType: kind.subject,
|
|
768
|
+
subjectId,
|
|
769
|
+
subjectRevisionHash,
|
|
770
|
+
requestKey,
|
|
771
|
+
requestedBy: principal.id,
|
|
772
|
+
requestedByType: principal.type,
|
|
773
|
+
summary,
|
|
774
|
+
status: "pending",
|
|
775
|
+
requiredApprovals: rules.requiredApprovals,
|
|
776
|
+
approvalCount: 0,
|
|
777
|
+
requiredPermissions: JSON.stringify(rules.requiredPermissions),
|
|
778
|
+
version: 1,
|
|
779
|
+
expiresAt: new Date(now.getTime() + rules.ttlMs),
|
|
780
|
+
...serviceWriteOption()
|
|
781
|
+
});
|
|
782
|
+
const id = String(request.id);
|
|
783
|
+
await this.appendEvent(tx, {
|
|
784
|
+
tenantId,
|
|
785
|
+
requestId: id,
|
|
786
|
+
type: "created",
|
|
787
|
+
principal,
|
|
788
|
+
reason: summary,
|
|
789
|
+
subjectRevisionHash,
|
|
790
|
+
sequence: 1,
|
|
791
|
+
now
|
|
792
|
+
});
|
|
793
|
+
return {
|
|
794
|
+
kind: "done",
|
|
795
|
+
outcome: "transitioned",
|
|
796
|
+
id
|
|
797
|
+
};
|
|
798
|
+
});
|
|
799
|
+
} catch (error) {
|
|
800
|
+
if (!isUniqueViolationError(error)) throw error;
|
|
801
|
+
const [winner] = await (await ApprovalRequestCollection.create({ db: this.db })).list({
|
|
802
|
+
where: {
|
|
803
|
+
tenantId,
|
|
804
|
+
requestKey
|
|
805
|
+
},
|
|
806
|
+
limit: 1
|
|
807
|
+
});
|
|
808
|
+
if (!winner) throw error;
|
|
809
|
+
outcome = sameContent(winner) ? {
|
|
810
|
+
kind: "done",
|
|
811
|
+
outcome: "already_applied",
|
|
812
|
+
id: String(winner.id)
|
|
813
|
+
} : refusal("request_key_conflict", `requestKey '${requestKey}' already names a different request`);
|
|
814
|
+
}
|
|
815
|
+
return await this.toResult(principal.tenantId, outcome);
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Record a decision. Only a `human` principal of the request's tenant who
|
|
820
|
+
* is not the requester and holds every required permission may decide,
|
|
821
|
+
* once, while the request is pending and unexpired. `approve` counts
|
|
822
|
+
* toward the quorum of distinct approvers; `reject` and `request_changes`
|
|
823
|
+
* end the request.
|
|
824
|
+
*/
|
|
825
|
+
async decide(principal, requestId, input) {
|
|
826
|
+
assertPrincipal(principal);
|
|
827
|
+
const eventType = DECISION_EVENT[input?.decision];
|
|
828
|
+
if (!eventType) throw new ApprovalError("APPROVAL_INVALID", `Unknown decision '${String(input?.decision)}'.`);
|
|
829
|
+
const reason = assertText(input.reason, "reason");
|
|
830
|
+
if (input.decision !== "approve" && !reason) throw new ApprovalError("APPROVAL_INVALID", `A '${input.decision}' decision requires a reason.`);
|
|
831
|
+
const tenantId = principal.tenantId;
|
|
832
|
+
return await this.transition(principal, requestId, async (tx, request, now) => {
|
|
833
|
+
if (principal.type !== "human") return refusal("not_human", `a ${principal.type} principal cannot decide`, request.id);
|
|
834
|
+
if (principal.id === request.requestedBy) return refusal("self_approval", "the requester cannot decide their own request", request.id);
|
|
835
|
+
const missing = (await this.requiredPermissionsFor(tx, request)).filter((slug) => !principal.can(slug));
|
|
836
|
+
if (missing.length > 0) return refusal("forbidden", `missing permission: ${missing.join(", ")}`, request.id);
|
|
837
|
+
if (request.status !== "pending") return refusal("not_pending", `request is ${request.status}`, request.id);
|
|
838
|
+
if (!(request.expiresAtMs() > now.getTime())) return refusal("expired", "the request expired", request.id);
|
|
839
|
+
const events = await this.txCollection(ApprovalEventCollection, tx);
|
|
840
|
+
if ((await events.list({
|
|
841
|
+
where: {
|
|
842
|
+
tenantId,
|
|
843
|
+
requestId: request.id,
|
|
844
|
+
voteKey: principal.id
|
|
845
|
+
},
|
|
846
|
+
limit: 1
|
|
847
|
+
})).length > 0) return refusal("already_decided", "this principal already decided", request.id);
|
|
848
|
+
const version = Number(request.version);
|
|
849
|
+
const nowIso = isoNow(now);
|
|
850
|
+
let updated;
|
|
851
|
+
if (input.decision === "approve") {
|
|
852
|
+
const voters = countedApprovers(await events.list({
|
|
853
|
+
where: {
|
|
854
|
+
tenantId,
|
|
855
|
+
requestId: request.id,
|
|
856
|
+
type: "approved"
|
|
857
|
+
},
|
|
858
|
+
limit: 1e3
|
|
859
|
+
}), request);
|
|
860
|
+
voters.add(principal.id);
|
|
861
|
+
const count = voters.size;
|
|
862
|
+
const reached = count >= Number(request.requiredApprovals);
|
|
863
|
+
updated = await tx.query(`UPDATE ${REQUESTS_TABLE}
|
|
864
|
+
SET approval_count = ?, status = ?, decided_at = ${reached ? "?" : "decided_at"},
|
|
865
|
+
version = version + 1, updated_at = ?
|
|
866
|
+
WHERE id = ? AND tenant_id = ? AND status = 'pending'
|
|
867
|
+
AND version = ? AND expires_at > ?
|
|
868
|
+
RETURNING version`, ...[
|
|
869
|
+
count,
|
|
870
|
+
reached ? "approved" : "pending",
|
|
871
|
+
...reached ? [nowIso] : [],
|
|
872
|
+
nowIso,
|
|
873
|
+
request.id,
|
|
874
|
+
tenantId,
|
|
875
|
+
version,
|
|
876
|
+
nowIso
|
|
877
|
+
]);
|
|
878
|
+
} else {
|
|
879
|
+
const status = input.decision === "reject" ? "rejected" : "changes_requested";
|
|
880
|
+
updated = await tx.query(`UPDATE ${REQUESTS_TABLE}
|
|
881
|
+
SET status = ?, decided_at = ?, version = version + 1, updated_at = ?
|
|
882
|
+
WHERE id = ? AND tenant_id = ? AND status = 'pending'
|
|
883
|
+
AND version = ? AND expires_at > ?
|
|
884
|
+
RETURNING version`, status, nowIso, nowIso, request.id, tenantId, version, nowIso);
|
|
885
|
+
}
|
|
886
|
+
const next = rowVersion(updated.rows);
|
|
887
|
+
if (next === null) return { kind: "retry" };
|
|
888
|
+
await this.appendEvent(tx, {
|
|
889
|
+
tenantId,
|
|
890
|
+
requestId: String(request.id),
|
|
891
|
+
type: eventType,
|
|
892
|
+
principal,
|
|
893
|
+
reason,
|
|
894
|
+
subjectRevisionHash: request.subjectRevisionHash,
|
|
895
|
+
sequence: next,
|
|
896
|
+
voteKey: principal.id,
|
|
897
|
+
now
|
|
898
|
+
});
|
|
899
|
+
await this.recordChange(tx, String(request.id), tenantId);
|
|
900
|
+
return {
|
|
901
|
+
kind: "done",
|
|
902
|
+
outcome: "transitioned",
|
|
903
|
+
id: String(request.id)
|
|
904
|
+
};
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Cancel a pending request. The requester may cancel their own request;
|
|
909
|
+
* anyone else needs `approvals.cancel-any`.
|
|
910
|
+
*/
|
|
911
|
+
async cancel(principal, requestId, input = {}) {
|
|
912
|
+
assertPrincipal(principal);
|
|
913
|
+
const reason = assertText(input.reason, "reason");
|
|
914
|
+
const tenantId = principal.tenantId;
|
|
915
|
+
return await this.transition(principal, requestId, async (tx, request, now) => {
|
|
916
|
+
if (principal.id !== request.requestedBy && !principal.can("approvals.cancel-any")) return refusal("forbidden", `only the requester or a holder of ${CANCEL_ANY_APPROVAL_PERMISSION} may cancel`, request.id);
|
|
917
|
+
if (request.status !== "pending") return refusal("not_pending", `request is ${request.status}`, request.id);
|
|
918
|
+
if (!(request.expiresAtMs() > now.getTime())) return refusal("expired", "the request expired", request.id);
|
|
919
|
+
const nowIso = isoNow(now);
|
|
920
|
+
const next = rowVersion((await tx.query(`UPDATE ${REQUESTS_TABLE}
|
|
921
|
+
SET status = 'cancelled', decided_at = ?, version = version + 1, updated_at = ?
|
|
922
|
+
WHERE id = ? AND tenant_id = ? AND status = 'pending'
|
|
923
|
+
AND version = ? AND expires_at > ?
|
|
924
|
+
RETURNING version`, nowIso, nowIso, request.id, tenantId, Number(request.version), nowIso)).rows);
|
|
925
|
+
if (next === null) return { kind: "retry" };
|
|
926
|
+
await this.appendEvent(tx, {
|
|
927
|
+
tenantId,
|
|
928
|
+
requestId: String(request.id),
|
|
929
|
+
type: "cancelled",
|
|
930
|
+
principal,
|
|
931
|
+
reason,
|
|
932
|
+
subjectRevisionHash: request.subjectRevisionHash,
|
|
933
|
+
sequence: next,
|
|
934
|
+
now
|
|
935
|
+
});
|
|
936
|
+
await this.recordChange(tx, String(request.id), tenantId);
|
|
937
|
+
return {
|
|
938
|
+
kind: "done",
|
|
939
|
+
outcome: "transitioned",
|
|
940
|
+
id: String(request.id)
|
|
941
|
+
};
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* Mark one request expired: a pending request, or an approved one that
|
|
946
|
+
* was never consumed, whose expiry has passed. Needs no permission (it
|
|
947
|
+
* only records what time already decided), so a job may call it.
|
|
948
|
+
*/
|
|
949
|
+
async expire(principal, requestId) {
|
|
950
|
+
assertPrincipal(principal);
|
|
951
|
+
const tenantId = principal.tenantId;
|
|
952
|
+
return await this.transition(principal, requestId, async (tx, request, now) => {
|
|
953
|
+
if (!(request.status === "pending" || request.status === "approved" && !request.isConsumed())) return refusal("not_pending", request.status === "approved" ? "the approval was consumed" : `request is ${request.status}`, request.id);
|
|
954
|
+
if (request.expiresAtMs() > now.getTime()) return refusal("not_expired", "the request has not expired", request.id);
|
|
955
|
+
const nowIso = isoNow(now);
|
|
956
|
+
const next = rowVersion((await tx.query(`UPDATE ${REQUESTS_TABLE}
|
|
957
|
+
SET status = 'expired', decided_at = ?, version = version + 1, updated_at = ?
|
|
958
|
+
WHERE id = ? AND tenant_id = ? AND status IN ('pending', 'approved')
|
|
959
|
+
AND consumed_at IS NULL AND version = ? AND expires_at <= ?
|
|
960
|
+
RETURNING version`, nowIso, nowIso, request.id, tenantId, Number(request.version), nowIso)).rows);
|
|
961
|
+
if (next === null) return { kind: "retry" };
|
|
962
|
+
await this.appendEvent(tx, {
|
|
963
|
+
tenantId,
|
|
964
|
+
requestId: String(request.id),
|
|
965
|
+
type: "expired",
|
|
966
|
+
principal,
|
|
967
|
+
reason: "",
|
|
968
|
+
subjectRevisionHash: request.subjectRevisionHash,
|
|
969
|
+
sequence: next,
|
|
970
|
+
now
|
|
971
|
+
});
|
|
972
|
+
await this.recordChange(tx, String(request.id), tenantId);
|
|
973
|
+
return {
|
|
974
|
+
kind: "done",
|
|
975
|
+
outcome: "transitioned",
|
|
976
|
+
id: String(request.id)
|
|
977
|
+
};
|
|
978
|
+
});
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Expire every due request in the principal's tenant: pending requests
|
|
982
|
+
* and unconsumed approvals past their deadline, oldest expiry first, at
|
|
983
|
+
* most `limit`. Returns the ids this call expired.
|
|
984
|
+
*/
|
|
985
|
+
async expireDue(principal, options = {}) {
|
|
986
|
+
assertPrincipal(principal);
|
|
987
|
+
const limit = Math.min(Math.max(1, Math.trunc(options.limit ?? 100)), 1e3);
|
|
988
|
+
const now = this.clock();
|
|
989
|
+
const candidates = await this.inTenant(principal, async () => {
|
|
990
|
+
return await (await ApprovalRequestCollection.create({ db: this.db })).list({
|
|
991
|
+
where: {
|
|
992
|
+
tenantId: principal.tenantId,
|
|
993
|
+
status: ["pending", "approved"],
|
|
994
|
+
consumedAt: null,
|
|
995
|
+
"expiresAt <=": now
|
|
996
|
+
},
|
|
997
|
+
orderBy: "expires_at ASC",
|
|
998
|
+
limit
|
|
999
|
+
});
|
|
1000
|
+
});
|
|
1001
|
+
const expired = [];
|
|
1002
|
+
for (const candidate of candidates) if ((await this.expire(principal, String(candidate.id))).outcome === "transitioned") expired.push(String(candidate.id));
|
|
1003
|
+
return expired;
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Consume an approval exactly once. The presented hash must equal the
|
|
1007
|
+
* revision that was approved; the request must be approved, unconsumed,
|
|
1008
|
+
* and unexpired. Any principal type of the tenant may consume (the
|
|
1009
|
+
* consumer's own service is the executor). Pass `options.transaction` to
|
|
1010
|
+
* make the consumption part of the consumer's own transaction.
|
|
1011
|
+
*/
|
|
1012
|
+
async consume(principal, requestId, subjectRevisionHash, options = {}) {
|
|
1013
|
+
assertPrincipal(principal);
|
|
1014
|
+
const hash = assertToken(subjectRevisionHash, "subjectRevisionHash");
|
|
1015
|
+
const tenantId = principal.tenantId;
|
|
1016
|
+
return await this.transition(principal, requestId, async (tx, request, now) => {
|
|
1017
|
+
if (request.status !== "approved") return refusal("not_approved", `request is ${request.status}`, request.id);
|
|
1018
|
+
if (request.isConsumed()) return refusal("already_consumed", "the approval was already used", request.id);
|
|
1019
|
+
if (request.subjectRevisionHash !== hash) return refusal("revision_mismatch", "the subject changed after it was approved", request.id);
|
|
1020
|
+
if (!(request.expiresAtMs() > now.getTime())) return refusal("expired", "the approval expired", request.id);
|
|
1021
|
+
if (!await this.ledgerBacksApproval(tx, request)) return refusal("unbacked_approval", "the ledger does not record this approval", request.id);
|
|
1022
|
+
const nowIso = isoNow(now);
|
|
1023
|
+
const next = rowVersion((await tx.query(`UPDATE ${REQUESTS_TABLE}
|
|
1024
|
+
SET consumed_at = ?, consumed_by = ?, version = version + 1, updated_at = ?
|
|
1025
|
+
WHERE id = ? AND tenant_id = ? AND status = 'approved'
|
|
1026
|
+
AND consumed_at IS NULL AND subject_revision_hash = ?
|
|
1027
|
+
AND version = ? AND expires_at > ?
|
|
1028
|
+
RETURNING version`, nowIso, principal.id, nowIso, request.id, tenantId, hash, Number(request.version), nowIso)).rows);
|
|
1029
|
+
if (next === null) return { kind: "retry" };
|
|
1030
|
+
await this.appendEvent(tx, {
|
|
1031
|
+
tenantId,
|
|
1032
|
+
requestId: String(request.id),
|
|
1033
|
+
type: "consumed",
|
|
1034
|
+
principal,
|
|
1035
|
+
reason: "",
|
|
1036
|
+
subjectRevisionHash: hash,
|
|
1037
|
+
sequence: next,
|
|
1038
|
+
now
|
|
1039
|
+
});
|
|
1040
|
+
await this.recordChange(tx, String(request.id), tenantId);
|
|
1041
|
+
return {
|
|
1042
|
+
kind: "done",
|
|
1043
|
+
outcome: "transitioned",
|
|
1044
|
+
id: String(request.id)
|
|
1045
|
+
};
|
|
1046
|
+
}, options.transaction);
|
|
1047
|
+
}
|
|
1048
|
+
/**
|
|
1049
|
+
* Write the tenant's tightening of a kind. Needs
|
|
1050
|
+
* `approvals.manage-policy`; a rule that would loosen the kind's defaults
|
|
1051
|
+
* throws `APPROVAL_POLICY_LOOSENS`. `0`/`''` clear a rule back to the
|
|
1052
|
+
* default. Applies to requests made afterwards.
|
|
1053
|
+
*/
|
|
1054
|
+
async setPolicy(principal, kindKey, rules) {
|
|
1055
|
+
assertPrincipal(principal);
|
|
1056
|
+
if (principal.type !== "human" || !principal.can("approvals.manage-policy")) throw new ApprovalError("APPROVAL_FORBIDDEN", `Setting approval policy needs a human principal holding ${MANAGE_APPROVAL_POLICY_PERMISSION}.`);
|
|
1057
|
+
const kind = requireApprovalKind(typeof kindKey === "string" ? kindKey : kindKey?.key);
|
|
1058
|
+
const normalized = {
|
|
1059
|
+
requiredApprovals: rules.requiredApprovals ?? 0,
|
|
1060
|
+
ttlMs: rules.ttlMs ?? 0,
|
|
1061
|
+
requiredPermission: String(rules.requiredPermission ?? "").trim()
|
|
1062
|
+
};
|
|
1063
|
+
if (normalized.requiredPermission && !isPermissionSlug(normalized.requiredPermission)) throw new ApprovalError("APPROVAL_INVALID", `requiredPermission must be a permission slug, got '${normalized.requiredPermission}'.`);
|
|
1064
|
+
assertPolicyTightens(kind, normalized);
|
|
1065
|
+
const tenantId = principal.tenantId;
|
|
1066
|
+
return await this.inTenant(principal, async () => {
|
|
1067
|
+
const id = await this.runTransaction(async (tx) => {
|
|
1068
|
+
const policies2 = await this.txCollection(ApprovalPolicyCollection, tx);
|
|
1069
|
+
const [existing] = await policies2.list({
|
|
1070
|
+
where: {
|
|
1071
|
+
tenantId,
|
|
1072
|
+
kind: kind.key
|
|
1073
|
+
},
|
|
1074
|
+
limit: 1
|
|
1075
|
+
});
|
|
1076
|
+
if (existing) {
|
|
1077
|
+
existing.requiredApprovals = normalized.requiredApprovals;
|
|
1078
|
+
existing.ttlMs = normalized.ttlMs;
|
|
1079
|
+
existing.requiredPermission = normalized.requiredPermission;
|
|
1080
|
+
existing.updatedBy = principal.id;
|
|
1081
|
+
await existing.save();
|
|
1082
|
+
return String(existing.id);
|
|
1083
|
+
}
|
|
1084
|
+
const created = await policies2.create({
|
|
1085
|
+
tenantId,
|
|
1086
|
+
kind: kind.key,
|
|
1087
|
+
...normalized,
|
|
1088
|
+
updatedBy: principal.id
|
|
1089
|
+
});
|
|
1090
|
+
return String(created.id);
|
|
1091
|
+
});
|
|
1092
|
+
const [saved] = await (await ApprovalPolicyCollection.create({ db: this.db })).list({
|
|
1093
|
+
where: {
|
|
1094
|
+
tenantId,
|
|
1095
|
+
id
|
|
1096
|
+
},
|
|
1097
|
+
limit: 1
|
|
1098
|
+
});
|
|
1099
|
+
if (!saved) throw new ApprovalError("APPROVAL_INVALID", "Saved approval policy vanished.");
|
|
1100
|
+
return saved;
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
/** The effective rules a new request of `kind` would get in the tenant. */
|
|
1104
|
+
async rulesFor(principal, kindKey) {
|
|
1105
|
+
assertPrincipal(principal);
|
|
1106
|
+
const kind = requireApprovalKind(typeof kindKey === "string" ? kindKey : kindKey?.key);
|
|
1107
|
+
return await this.inTenant(principal, async () => effectiveRules(kind, await this.loadPolicy(this.db, principal.tenantId, kind.key)));
|
|
1108
|
+
}
|
|
1109
|
+
/**
|
|
1110
|
+
* Run a guarded transition with retry. `body` runs inside a transaction
|
|
1111
|
+
* on a fresh read of the request and returns `retry` when its guarded
|
|
1112
|
+
* UPDATE matched no row (a concurrent transition won).
|
|
1113
|
+
*/
|
|
1114
|
+
async transition(principal, requestId, body, callerTransaction) {
|
|
1115
|
+
if (!UUID_RE.test(String(requestId ?? ""))) return {
|
|
1116
|
+
outcome: "refused",
|
|
1117
|
+
request: null,
|
|
1118
|
+
refusal: {
|
|
1119
|
+
reason: "not_found",
|
|
1120
|
+
detail: "not a request id"
|
|
1121
|
+
}
|
|
1122
|
+
};
|
|
1123
|
+
if (this.contextMismatch(principal)) return ApprovalService.mismatchResult();
|
|
1124
|
+
const tenantId = principal.tenantId;
|
|
1125
|
+
return await this.inTenant(principal, async () => {
|
|
1126
|
+
const attempt = async (tx) => {
|
|
1127
|
+
const request = await this.loadRequest(tx, tenantId, requestId);
|
|
1128
|
+
if (!request) return refusal("not_found", "no such request in this tenant");
|
|
1129
|
+
return await body(tx, request, this.clock());
|
|
1130
|
+
};
|
|
1131
|
+
if (callerTransaction) {
|
|
1132
|
+
const outcome = await attempt(callerTransaction);
|
|
1133
|
+
if (outcome.kind === "retry") return await this.toResult(tenantId, refusal("contention", "a concurrent transition won", requestId), callerTransaction);
|
|
1134
|
+
return await this.toResult(tenantId, outcome, callerTransaction);
|
|
1135
|
+
}
|
|
1136
|
+
for (let i = 0; i < this.maxAttempts; i++) {
|
|
1137
|
+
let outcome;
|
|
1138
|
+
try {
|
|
1139
|
+
outcome = await this.runTransaction(attempt);
|
|
1140
|
+
} catch (error) {
|
|
1141
|
+
if (isUniqueViolationError(error)) continue;
|
|
1142
|
+
throw error;
|
|
1143
|
+
}
|
|
1144
|
+
if (outcome.kind !== "retry") return await this.toResult(tenantId, outcome);
|
|
1145
|
+
}
|
|
1146
|
+
return await this.toResult(tenantId, refusal("contention", "concurrent transitions kept winning", requestId));
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
/** True when an active tenant context names a different tenant. */
|
|
1150
|
+
contextMismatch(principal) {
|
|
1151
|
+
const active = getTenantId();
|
|
1152
|
+
return !isSystemContext() && active !== void 0 && active !== principal.tenantId;
|
|
1153
|
+
}
|
|
1154
|
+
/** The refusal returned when {@link contextMismatch} holds. */
|
|
1155
|
+
static mismatchResult() {
|
|
1156
|
+
return {
|
|
1157
|
+
outcome: "refused",
|
|
1158
|
+
request: null,
|
|
1159
|
+
refusal: {
|
|
1160
|
+
reason: "tenant_mismatch",
|
|
1161
|
+
detail: "the principal's tenant does not match the active tenant context"
|
|
1162
|
+
}
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
/** Pin the work to the principal's tenant; refuse a conflicting context. */
|
|
1166
|
+
async inTenant(principal, fn) {
|
|
1167
|
+
if (this.contextMismatch(principal)) throw new ApprovalError("APPROVAL_FORBIDDEN", `The principal's tenant does not match the active tenant context.`);
|
|
1168
|
+
return await withTenant({ tenantId: principal.tenantId }, fn);
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* Run `fn` in a transaction through core's embedded write queue.
|
|
1172
|
+
*
|
|
1173
|
+
* On embedded engines (SQLite, DuckDB, libsql) the transaction takes the
|
|
1174
|
+
* queue for its database BEFORE the adapter's connection lock, and the
|
|
1175
|
+
* model writes inside it (event inserts, change-feed appends) re-enter that
|
|
1176
|
+
* hold. Taking the connection lock first deadlocks against an unrelated
|
|
1177
|
+
* save that holds the queue and then opens its own write transaction, and
|
|
1178
|
+
* lets other root writes land inside this BEGIN..COMMIT. The queue also
|
|
1179
|
+
* serializes these transactions per database in-process, which is what
|
|
1180
|
+
* the former per-handle promise chain did, so that chain is gone.
|
|
1181
|
+
* PostgreSQL is not queued: each transaction has its own pooled
|
|
1182
|
+
* connection and the guarded UPDATE's row lock arbitrates.
|
|
1183
|
+
*/
|
|
1184
|
+
async runTransaction(fn) {
|
|
1185
|
+
return await withEmbeddedWriteTransaction(this.db, isEmbeddedDatabase(this.db), fn);
|
|
1186
|
+
}
|
|
1187
|
+
/** A collection bound to a transaction database. */
|
|
1188
|
+
async txCollection(Collection, tx) {
|
|
1189
|
+
return await Collection.create({
|
|
1190
|
+
db: tx,
|
|
1191
|
+
_reuseInitializedDb: true,
|
|
1192
|
+
_deferRuntimeInitialization: true
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
async loadRequest(db, tenantId, requestId) {
|
|
1196
|
+
const [request] = await (db === this.db ? await ApprovalRequestCollection.create({ db }) : await this.txCollection(ApprovalRequestCollection, db)).list({
|
|
1197
|
+
where: {
|
|
1198
|
+
tenantId,
|
|
1199
|
+
id: requestId
|
|
1200
|
+
},
|
|
1201
|
+
limit: 1
|
|
1202
|
+
});
|
|
1203
|
+
return request && request.tenantId === tenantId ? request : null;
|
|
1204
|
+
}
|
|
1205
|
+
async loadPolicy(db, tenantId, kind) {
|
|
1206
|
+
const [policy] = await (db === this.db ? await ApprovalPolicyCollection.create({ db }) : await this.txCollection(ApprovalPolicyCollection, db)).list({
|
|
1207
|
+
where: {
|
|
1208
|
+
tenantId,
|
|
1209
|
+
kind
|
|
1210
|
+
},
|
|
1211
|
+
limit: 1
|
|
1212
|
+
});
|
|
1213
|
+
return policy ?? null;
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* Every slug a decider must hold now: the slugs fixed at request time
|
|
1217
|
+
* plus the kind's and the tenant policy's current ones (tightening only:
|
|
1218
|
+
* a later policy can add a slug, never remove one).
|
|
1219
|
+
*/
|
|
1220
|
+
async requiredPermissionsFor(tx, request) {
|
|
1221
|
+
const kind = requireApprovalKind(request.kind);
|
|
1222
|
+
const current = effectiveRules(kind, await this.loadPolicy(tx, request.tenantId, kind.key));
|
|
1223
|
+
const slugs = new Set(request.getRequiredPermissions());
|
|
1224
|
+
for (const slug of current.requiredPermissions) slugs.add(slug);
|
|
1225
|
+
return [...slugs];
|
|
1226
|
+
}
|
|
1227
|
+
async appendEvent(tx, input) {
|
|
1228
|
+
await (await this.txCollection(ApprovalEventCollection, tx)).create({
|
|
1229
|
+
tenantId: input.tenantId,
|
|
1230
|
+
requestId: input.requestId,
|
|
1231
|
+
type: input.type,
|
|
1232
|
+
actorId: input.principal.id,
|
|
1233
|
+
actorType: input.principal.type,
|
|
1234
|
+
reason: input.reason,
|
|
1235
|
+
subjectRevisionHash: input.subjectRevisionHash,
|
|
1236
|
+
sequence: input.sequence,
|
|
1237
|
+
voteKey: input.voteKey ?? null,
|
|
1238
|
+
occurredAt: input.now,
|
|
1239
|
+
...serviceWriteOption()
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
/**
|
|
1243
|
+
* Defence in depth for `consume`: the request row says `approved`, so the
|
|
1244
|
+
* ledger must agree. It needs the `created` event at sequence 1, no
|
|
1245
|
+
* terminal event, and approvals from at least `requiredApprovals` distinct
|
|
1246
|
+
* human actors other than the requester. Catches a status written around
|
|
1247
|
+
* the service (raw SQL); model-layer inserts are already capability-gated.
|
|
1248
|
+
*/
|
|
1249
|
+
async ledgerBacksApproval(tx, request) {
|
|
1250
|
+
const ledger = await (await this.txCollection(ApprovalEventCollection, tx)).list({
|
|
1251
|
+
where: {
|
|
1252
|
+
tenantId: request.tenantId,
|
|
1253
|
+
requestId: request.id
|
|
1254
|
+
},
|
|
1255
|
+
limit: 1e3
|
|
1256
|
+
});
|
|
1257
|
+
const created = ledger.some((event) => event.type === "created" && Number(event.sequence) === 1);
|
|
1258
|
+
const terminal = ledger.some((event) => [
|
|
1259
|
+
"rejected",
|
|
1260
|
+
"changes_requested",
|
|
1261
|
+
"cancelled",
|
|
1262
|
+
"expired",
|
|
1263
|
+
"consumed"
|
|
1264
|
+
].includes(event.type));
|
|
1265
|
+
const approvers = countedApprovers(ledger.filter((event) => event.type === "approved"), request);
|
|
1266
|
+
return created && !terminal && approvers.size >= Math.max(1, Number(request.requiredApprovals));
|
|
1267
|
+
}
|
|
1268
|
+
/**
|
|
1269
|
+
* The guarded UPDATE bypasses save hooks, so record the change-feed entry
|
|
1270
|
+
* the save path would have written. Best effort, like the save path.
|
|
1271
|
+
*/
|
|
1272
|
+
async recordChange(tx, requestId, tenantId) {
|
|
1273
|
+
try {
|
|
1274
|
+
await appendChange(tx, {
|
|
1275
|
+
table: REQUESTS_TABLE,
|
|
1276
|
+
rowId: requestId,
|
|
1277
|
+
operation: "update",
|
|
1278
|
+
tenantId
|
|
1279
|
+
});
|
|
1280
|
+
} catch {}
|
|
1281
|
+
}
|
|
1282
|
+
/** Re-read the request after commit (ids only cross the boundary). */
|
|
1283
|
+
async toResult(tenantId, outcome, db = this.db) {
|
|
1284
|
+
if (outcome.kind === "retry") throw new Error("ApprovalService: unresolved retry outcome");
|
|
1285
|
+
const id = outcome.id;
|
|
1286
|
+
const request = id ? await this.loadRequest(db, tenantId, id) : null;
|
|
1287
|
+
if (outcome.kind === "refused") return {
|
|
1288
|
+
outcome: "refused",
|
|
1289
|
+
request: outcome.reason === "not_found" ? null : request,
|
|
1290
|
+
refusal: {
|
|
1291
|
+
reason: outcome.reason,
|
|
1292
|
+
detail: outcome.detail
|
|
1293
|
+
}
|
|
1294
|
+
};
|
|
1295
|
+
return {
|
|
1296
|
+
outcome: outcome.outcome,
|
|
1297
|
+
request
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
};
|
|
1301
|
+
//#endregion
|
|
1302
|
+
//#region src/index.ts
|
|
1303
|
+
ensureApprovalPermissionsRegistered();
|
|
1304
|
+
//#endregion
|
|
1305
|
+
export { APPROVAL_EVENT_TYPES, APPROVAL_PERMISSION_DEFINITIONS, APPROVAL_PRINCIPAL_TYPES, APPROVAL_STATUSES, ApprovalError, ApprovalEvent, ApprovalEventCollection, ApprovalPolicy, ApprovalPolicyCollection, ApprovalRequest, ApprovalRequestCollection, ApprovalService, CANCEL_ANY_APPROVAL_PERMISSION, DEFAULT_APPROVAL_TTL_MS, MANAGE_APPROVAL_POLICY_PERMISSION, MAX_APPROVAL_TTL_MS, MAX_REQUIRED_APPROVALS, MIN_APPROVAL_TTL_MS, approvalPrincipalFromPermissions, assertPolicyTightens, defineApprovalKind, effectiveRules, ensureApprovalPermissionsRegistered, getApprovalKind, listApprovalKinds, registerApprovalPermissions, requireApprovalKind, unregisterApprovalKind };
|
|
1306
|
+
|
|
1307
|
+
//# sourceMappingURL=index.js.map
|