@synoikia/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (209) hide show
  1. package/LICENSE +21 -0
  2. package/dist/app.d.ts +79 -0
  3. package/dist/app.js +155 -0
  4. package/dist/app.js.map +1 -0
  5. package/dist/approvals/links.d.ts +23 -0
  6. package/dist/approvals/links.js +49 -0
  7. package/dist/approvals/links.js.map +1 -0
  8. package/dist/approvals/service.d.ts +110 -0
  9. package/dist/approvals/service.js +182 -0
  10. package/dist/approvals/service.js.map +1 -0
  11. package/dist/audit-query.d.ts +52 -0
  12. package/dist/audit-query.js +116 -0
  13. package/dist/audit-query.js.map +1 -0
  14. package/dist/audit.d.ts +5 -0
  15. package/dist/audit.js +8 -0
  16. package/dist/audit.js.map +1 -0
  17. package/dist/auth/mcp-auth.d.ts +50 -0
  18. package/dist/auth/mcp-auth.js +160 -0
  19. package/dist/auth/mcp-auth.js.map +1 -0
  20. package/dist/auth/mcp-tokens.d.ts +57 -0
  21. package/dist/auth/mcp-tokens.js +143 -0
  22. package/dist/auth/mcp-tokens.js.map +1 -0
  23. package/dist/auth/oauth.d.ts +188 -0
  24. package/dist/auth/oauth.js +470 -0
  25. package/dist/auth/oauth.js.map +1 -0
  26. package/dist/auth/oidc.d.ts +110 -0
  27. package/dist/auth/oidc.js +221 -0
  28. package/dist/auth/oidc.js.map +1 -0
  29. package/dist/auth/sessions.d.ts +34 -0
  30. package/dist/auth/sessions.js +83 -0
  31. package/dist/auth/sessions.js.map +1 -0
  32. package/dist/auth/throttle.d.ts +31 -0
  33. package/dist/auth/throttle.js +52 -0
  34. package/dist/auth/throttle.js.map +1 -0
  35. package/dist/auth/tokens.d.ts +7 -0
  36. package/dist/auth/tokens.js +40 -0
  37. package/dist/auth/tokens.js.map +1 -0
  38. package/dist/auth/totp.d.ts +14 -0
  39. package/dist/auth/totp.js +81 -0
  40. package/dist/auth/totp.js.map +1 -0
  41. package/dist/auth/users.d.ts +70 -0
  42. package/dist/auth/users.js +296 -0
  43. package/dist/auth/users.js.map +1 -0
  44. package/dist/catalog/groups.d.ts +102 -0
  45. package/dist/catalog/groups.js +363 -0
  46. package/dist/catalog/groups.js.map +1 -0
  47. package/dist/catalog/registry.d.ts +25 -0
  48. package/dist/catalog/registry.js +80 -0
  49. package/dist/catalog/registry.js.map +1 -0
  50. package/dist/catalog/rules.d.ts +128 -0
  51. package/dist/catalog/rules.js +178 -0
  52. package/dist/catalog/rules.js.map +1 -0
  53. package/dist/catalog/sync.d.ts +29 -0
  54. package/dist/catalog/sync.js +222 -0
  55. package/dist/catalog/sync.js.map +1 -0
  56. package/dist/cli.d.ts +3 -0
  57. package/dist/cli.js +83 -0
  58. package/dist/cli.js.map +1 -0
  59. package/dist/config/env.d.ts +40 -0
  60. package/dist/config/env.js +71 -0
  61. package/dist/config/env.js.map +1 -0
  62. package/dist/crypto/index.d.ts +28 -0
  63. package/dist/crypto/index.js +88 -0
  64. package/dist/crypto/index.js.map +1 -0
  65. package/dist/crypto/rotate.d.ts +15 -0
  66. package/dist/crypto/rotate.js +61 -0
  67. package/dist/crypto/rotate.js.map +1 -0
  68. package/dist/db/index.d.ts +17 -0
  69. package/dist/db/index.js +29 -0
  70. package/dist/db/index.js.map +1 -0
  71. package/dist/db/schema.d.ts +4389 -0
  72. package/dist/db/schema.js +372 -0
  73. package/dist/db/schema.js.map +1 -0
  74. package/dist/endpoints/slug.d.ts +3 -0
  75. package/dist/endpoints/slug.js +7 -0
  76. package/dist/endpoints/slug.js.map +1 -0
  77. package/dist/errors.d.ts +16 -0
  78. package/dist/errors.js +29 -0
  79. package/dist/errors.js.map +1 -0
  80. package/dist/events.d.ts +43 -0
  81. package/dist/events.js +16 -0
  82. package/dist/events.js.map +1 -0
  83. package/dist/gate/access.d.ts +47 -0
  84. package/dist/gate/access.js +48 -0
  85. package/dist/gate/access.js.map +1 -0
  86. package/dist/gate/attestation.d.ts +23 -0
  87. package/dist/gate/attestation.js +33 -0
  88. package/dist/gate/attestation.js.map +1 -0
  89. package/dist/gate/canonical.d.ts +3 -0
  90. package/dist/gate/canonical.js +11 -0
  91. package/dist/gate/canonical.js.map +1 -0
  92. package/dist/gate/match.d.ts +67 -0
  93. package/dist/gate/match.js +133 -0
  94. package/dist/gate/match.js.map +1 -0
  95. package/dist/gate/pipeline.d.ts +61 -0
  96. package/dist/gate/pipeline.js +284 -0
  97. package/dist/gate/pipeline.js.map +1 -0
  98. package/dist/gate/preapproval.d.ts +24 -0
  99. package/dist/gate/preapproval.js +48 -0
  100. package/dist/gate/preapproval.js.map +1 -0
  101. package/dist/gate/rate-limit.d.ts +35 -0
  102. package/dist/gate/rate-limit.js +82 -0
  103. package/dist/gate/rate-limit.js.map +1 -0
  104. package/dist/gate/redact.d.ts +23 -0
  105. package/dist/gate/redact.js +87 -0
  106. package/dist/gate/redact.js.map +1 -0
  107. package/dist/http/admin/auth.d.ts +33 -0
  108. package/dist/http/admin/auth.js +338 -0
  109. package/dist/http/admin/auth.js.map +1 -0
  110. package/dist/http/admin/instances.d.ts +5 -0
  111. package/dist/http/admin/instances.js +193 -0
  112. package/dist/http/admin/instances.js.map +1 -0
  113. package/dist/http/admin/system.d.ts +5 -0
  114. package/dist/http/admin/system.js +225 -0
  115. package/dist/http/admin/system.js.map +1 -0
  116. package/dist/http/admin-app.d.ts +9 -0
  117. package/dist/http/admin-app.js +51 -0
  118. package/dist/http/admin-app.js.map +1 -0
  119. package/dist/http/common.d.ts +32 -0
  120. package/dist/http/common.js +80 -0
  121. package/dist/http/common.js.map +1 -0
  122. package/dist/http/mcp/approval-routes.d.ts +11 -0
  123. package/dist/http/mcp/approval-routes.js +117 -0
  124. package/dist/http/mcp/approval-routes.js.map +1 -0
  125. package/dist/http/mcp/endpoint.d.ts +32 -0
  126. package/dist/http/mcp/endpoint.js +293 -0
  127. package/dist/http/mcp/endpoint.js.map +1 -0
  128. package/dist/http/mcp/oauth-routes.d.ts +29 -0
  129. package/dist/http/mcp/oauth-routes.js +465 -0
  130. package/dist/http/mcp/oauth-routes.js.map +1 -0
  131. package/dist/http/mcp/pages.d.ts +63 -0
  132. package/dist/http/mcp/pages.js +200 -0
  133. package/dist/http/mcp/pages.js.map +1 -0
  134. package/dist/http/mcp-app.d.ts +7 -0
  135. package/dist/http/mcp-app.js +30 -0
  136. package/dist/http/mcp-app.js.map +1 -0
  137. package/dist/instances/connection.d.ts +37 -0
  138. package/dist/instances/connection.js +109 -0
  139. package/dist/instances/connection.js.map +1 -0
  140. package/dist/instances/manager.d.ts +313 -0
  141. package/dist/instances/manager.js +600 -0
  142. package/dist/instances/manager.js.map +1 -0
  143. package/dist/instances/settings.d.ts +42 -0
  144. package/dist/instances/settings.js +57 -0
  145. package/dist/instances/settings.js.map +1 -0
  146. package/dist/lenient.d.ts +16 -0
  147. package/dist/lenient.js +51 -0
  148. package/dist/lenient.js.map +1 -0
  149. package/dist/lock.d.ts +28 -0
  150. package/dist/lock.js +77 -0
  151. package/dist/lock.js.map +1 -0
  152. package/dist/main.d.ts +1 -0
  153. package/dist/main.js +38 -0
  154. package/dist/main.js.map +1 -0
  155. package/dist/maintenance.d.ts +10 -0
  156. package/dist/maintenance.js +48 -0
  157. package/dist/maintenance.js.map +1 -0
  158. package/dist/notify/service.d.ts +149 -0
  159. package/dist/notify/service.js +358 -0
  160. package/dist/notify/service.js.map +1 -0
  161. package/dist/plugins/discovery.d.ts +39 -0
  162. package/dist/plugins/discovery.js +157 -0
  163. package/dist/plugins/discovery.js.map +1 -0
  164. package/dist/plugins/minisign.d.ts +25 -0
  165. package/dist/plugins/minisign.js +49 -0
  166. package/dist/plugins/minisign.js.map +1 -0
  167. package/dist/plugins/process.d.ts +55 -0
  168. package/dist/plugins/process.js +162 -0
  169. package/dist/plugins/process.js.map +1 -0
  170. package/dist/plugins/repos.d.ts +198 -0
  171. package/dist/plugins/repos.js +612 -0
  172. package/dist/plugins/repos.js.map +1 -0
  173. package/dist/plugins/supervisor.d.ts +50 -0
  174. package/dist/plugins/supervisor.js +125 -0
  175. package/dist/plugins/supervisor.js.map +1 -0
  176. package/dist/runtime/index.d.ts +28 -0
  177. package/dist/runtime/index.js +248 -0
  178. package/dist/runtime/index.js.map +1 -0
  179. package/dist/sandbox/index.d.ts +53 -0
  180. package/dist/sandbox/index.js +197 -0
  181. package/dist/sandbox/index.js.map +1 -0
  182. package/dist/server.d.ts +16 -0
  183. package/dist/server.js +36 -0
  184. package/dist/server.js.map +1 -0
  185. package/dist/settings.d.ts +43 -0
  186. package/dist/settings.js +99 -0
  187. package/dist/settings.js.map +1 -0
  188. package/dist/testing/index.d.ts +73 -0
  189. package/dist/testing/index.js +174 -0
  190. package/dist/testing/index.js.map +1 -0
  191. package/dist/version.d.ts +2 -0
  192. package/dist/version.js +4 -0
  193. package/dist/version.js.map +1 -0
  194. package/drizzle/0000_init.sql +334 -0
  195. package/drizzle/0001_totp_last_step.sql +1 -0
  196. package/drizzle/0002_access_levels.sql +16 -0
  197. package/drizzle/0003_drop_exclusion_flags.sql +2 -0
  198. package/drizzle/0004_grant_instance_ids.sql +1 -0
  199. package/drizzle/0005_rule_strict_miss.sql +1 -0
  200. package/drizzle/0006_attestation_waived.sql +1 -0
  201. package/drizzle/meta/0000_snapshot.json +2318 -0
  202. package/drizzle/meta/0001_snapshot.json +2325 -0
  203. package/drizzle/meta/0002_snapshot.json +2348 -0
  204. package/drizzle/meta/0003_snapshot.json +2332 -0
  205. package/drizzle/meta/0004_snapshot.json +2339 -0
  206. package/drizzle/meta/0005_snapshot.json +2346 -0
  207. package/drizzle/meta/0006_snapshot.json +2354 -0
  208. package/drizzle/meta/_journal.json +55 -0
  209. package/package.json +58 -0
@@ -0,0 +1,23 @@
1
+ import type { DbLike } from '../db/index.js';
2
+ /**
3
+ * Best-practice attestation (HA §3.6, design §5.1/§5.2 step 1). `search` hands out a key bound to
4
+ * (instance, operation, guide version, MCP session); `execute` must present it from the same session.
5
+ * Revising the guide invalidates every outstanding key at once, because the version is part of the
6
+ * MAC, and a key read in one session (or leaked into a prompt) is useless in any other.
7
+ */
8
+ export declare function issueAttestationKey(macKey: Buffer, instanceId: string, opKey: string, guideVersion: string, mcpSessionId?: string): string;
9
+ export declare function currentGuide(db: DbLike, instanceId: string, operationId: string): {
10
+ id: string;
11
+ instanceId: string;
12
+ operationId: string;
13
+ version: string;
14
+ content: string;
15
+ fetchedAt: Date;
16
+ } | undefined;
17
+ export declare function verifyAttestationKey(db: DbLike, macKey: Buffer, input: {
18
+ instanceId: string;
19
+ operationId: string;
20
+ opKey: string;
21
+ mcpSessionId?: string;
22
+ presented: unknown;
23
+ }): boolean;
@@ -0,0 +1,33 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ import { and, desc, eq } from 'drizzle-orm';
3
+ import { guides } from '../db/schema.js';
4
+ /**
5
+ * Best-practice attestation (HA §3.6, design §5.1/§5.2 step 1). `search` hands out a key bound to
6
+ * (instance, operation, guide version, MCP session); `execute` must present it from the same session.
7
+ * Revising the guide invalidates every outstanding key at once, because the version is part of the
8
+ * MAC, and a key read in one session (or leaked into a prompt) is useless in any other.
9
+ */
10
+ export function issueAttestationKey(macKey, instanceId, opKey, guideVersion, mcpSessionId = '') {
11
+ return createHmac('sha256', macKey)
12
+ .update(`${instanceId}\n${opKey}\n${guideVersion}\n${mcpSessionId}`)
13
+ .digest('base64url');
14
+ }
15
+ export function currentGuide(db, instanceId, operationId) {
16
+ return db
17
+ .select()
18
+ .from(guides)
19
+ .where(and(eq(guides.instanceId, instanceId), eq(guides.operationId, operationId)))
20
+ .orderBy(desc(guides.fetchedAt))
21
+ .get();
22
+ }
23
+ export function verifyAttestationKey(db, macKey, input) {
24
+ if (typeof input.presented !== 'string' || input.presented === '')
25
+ return false;
26
+ const guide = currentGuide(db, input.instanceId, input.operationId);
27
+ if (!guide)
28
+ return false;
29
+ const expected = Buffer.from(issueAttestationKey(macKey, input.instanceId, input.opKey, guide.version, input.mcpSessionId));
30
+ const presented = Buffer.from(input.presented);
31
+ return presented.length === expected.length && timingSafeEqual(presented, expected);
32
+ }
33
+ //# sourceMappingURL=attestation.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attestation.js","sourceRoot":"","sources":["../../src/gate/attestation.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC1D,OAAO,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AAE5C,OAAO,EAAE,MAAM,EAAE,MAAM,iBAAiB,CAAC;AAEzC;;;;;GAKG;AAEH,MAAM,UAAU,mBAAmB,CACjC,MAAc,EACd,UAAkB,EAClB,KAAa,EACb,YAAoB,EACpB,YAAY,GAAG,EAAE;IAEjB,OAAO,UAAU,CAAC,QAAQ,EAAE,MAAM,CAAC;SAChC,MAAM,CAAC,GAAG,UAAU,KAAK,KAAK,KAAK,YAAY,KAAK,YAAY,EAAE,CAAC;SACnE,MAAM,CAAC,WAAW,CAAC,CAAC;AACzB,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,EAAU,EAAE,UAAkB,EAAE,WAAmB;IAC9E,OAAO,EAAE;SACN,MAAM,EAAE;SACR,IAAI,CAAC,MAAM,CAAC;SACZ,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC,MAAM,CAAC,UAAU,EAAE,UAAU,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC,WAAW,EAAE,WAAW,CAAC,CAAC,CAAC;SAClF,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;SAC/B,GAAG,EAAE,CAAC;AACX,CAAC;AAED,MAAM,UAAU,oBAAoB,CAClC,EAAU,EACV,MAAc,EACd,KAA4G;IAE5G,IAAI,OAAO,KAAK,CAAC,SAAS,KAAK,QAAQ,IAAI,KAAK,CAAC,SAAS,KAAK,EAAE;QAAE,OAAO,KAAK,CAAC;IAChF,MAAM,KAAK,GAAG,YAAY,CAAC,EAAE,EAAE,KAAK,CAAC,UAAU,EAAE,KAAK,CAAC,WAAW,CAAC,CAAC;IACpE,IAAI,CAAC,KAAK;QAAE,OAAO,KAAK,CAAC;IACzB,MAAM,QAAQ,GAAG,MAAM,CAAC,IAAI,CAC1B,mBAAmB,CAAC,MAAM,EAAE,KAAK,CAAC,UAAU,EAAE,KAAK,CAAC,KAAK,EAAE,KAAK,CAAC,OAAO,EAAE,KAAK,CAAC,YAAY,CAAC,CAC9F,CAAC;IACF,MAAM,SAAS,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;IAC/C,OAAO,SAAS,CAAC,MAAM,KAAK,QAAQ,CAAC,MAAM,IAAI,eAAe,CAAC,SAAS,EAAE,QAAQ,CAAC,CAAC;AACtF,CAAC"}
@@ -0,0 +1,3 @@
1
+ /** JSON with object keys sorted recursively, so equal values always serialize identically. */
2
+ export declare function canonicalJson(value: unknown): string;
3
+ export declare function sha256Hex(input: string): string;
@@ -0,0 +1,11 @@
1
+ import { createHash } from 'node:crypto';
2
+ /** JSON with object keys sorted recursively, so equal values always serialize identically. */
3
+ export function canonicalJson(value) {
4
+ return (JSON.stringify(value, (_key, v) => v && typeof v === 'object' && !Array.isArray(v)
5
+ ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
6
+ : v) ?? 'null');
7
+ }
8
+ export function sha256Hex(input) {
9
+ return createHash('sha256').update(input).digest('hex');
10
+ }
11
+ //# sourceMappingURL=canonical.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"canonical.js","sourceRoot":"","sources":["../../src/gate/canonical.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC,8FAA8F;AAC9F,MAAM,UAAU,aAAa,CAAC,KAAc;IAC1C,OAAO,CACL,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,CAAC,IAAI,EAAE,CAAU,EAAE,EAAE,CACzC,CAAC,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC;QAC7C,CAAC,CAAC,MAAM,CAAC,WAAW,CAChB,MAAM,CAAC,OAAO,CAAC,CAA4B,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAC9F;QACH,CAAC,CAAC,CAAC,CACN,IAAI,MAAM,CACZ,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,KAAa;IACrC,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;AAC1D,CAAC"}
@@ -0,0 +1,67 @@
1
+ import type { ResolvedTarget } from '@synoikia/plugin-sdk';
2
+ import { z } from 'zod';
3
+ /**
4
+ * Pre-approval `match` evaluator (design §5.2). A rule's match is a list of conditions that must all
5
+ * hold. It fails closed: a missing field, a type mismatch or zero resolved targets means "no match",
6
+ * which only ever sends the call to a human.
7
+ *
8
+ * Matching is **strict**: every parameter of the call must be covered by a condition. A parameter the
9
+ * rule doesn't mention must be absent, unless the rule accepts it with `{ field, op: 'any' }` (`field: ''`
10
+ * accepts all parameters). Otherwise a rule for one field would also approve whatever else the model
11
+ * chose to send (a quota, an ACL, encryption options).
12
+ */
13
+ export declare const PARAM_OPS: readonly ["eq", "in", "prefix", "range", "bool", "any"];
14
+ export declare const ParamConditionSchema: z.ZodObject<{
15
+ field: z.ZodString;
16
+ op: z.ZodEnum<{
17
+ any: "any";
18
+ in: "in";
19
+ eq: "eq";
20
+ prefix: "prefix";
21
+ range: "range";
22
+ bool: "bool";
23
+ }>;
24
+ value: z.ZodOptional<z.ZodUnknown>;
25
+ }, z.core.$strip>;
26
+ export declare const TargetConditionSchema: z.ZodObject<{
27
+ field: z.ZodLiteral<"$targets">;
28
+ areas: z.ZodOptional<z.ZodArray<z.ZodString>>;
29
+ entities: z.ZodOptional<z.ZodArray<z.ZodString>>;
30
+ domains: z.ZodOptional<z.ZodArray<z.ZodString>>;
31
+ }, z.core.$strip>;
32
+ export declare const MatchSchema: z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
33
+ field: z.ZodLiteral<"$targets">;
34
+ areas: z.ZodOptional<z.ZodArray<z.ZodString>>;
35
+ entities: z.ZodOptional<z.ZodArray<z.ZodString>>;
36
+ domains: z.ZodOptional<z.ZodArray<z.ZodString>>;
37
+ }, z.core.$strip>, z.ZodObject<{
38
+ field: z.ZodString;
39
+ op: z.ZodEnum<{
40
+ any: "any";
41
+ in: "in";
42
+ eq: "eq";
43
+ prefix: "prefix";
44
+ range: "range";
45
+ bool: "bool";
46
+ }>;
47
+ value: z.ZodOptional<z.ZodUnknown>;
48
+ }, z.core.$strip>]>>;
49
+ export type MatchCondition = z.infer<typeof MatchSchema>[number];
50
+ /** RFC 6901 JSON pointer lookup; returns MISSING instead of undefined so `null` values still count. */
51
+ export declare function getPointer(doc: unknown, pointer: string): unknown;
52
+ /**
53
+ * Whether every parameter is covered by some condition: a condition on a path covers everything under
54
+ * it; an object or array only partly covered has each of its keys (or indexes: `/0/name`) checked in
55
+ * turn. Nothing (or `{}` / `[]`) is covered. Arrays matter for positional APIs such as TrueNAS, whose
56
+ * params are `[{ name, … }]`.
57
+ */
58
+ export declare function coversAllParams(match: readonly MatchCondition[], params: unknown): boolean;
59
+ /** The conditions alone, without the strict every-parameter check (used to spot rules strictness broke). */
60
+ export declare function conditionsHold(match: readonly MatchCondition[], call: {
61
+ params: unknown;
62
+ targets: readonly ResolvedTarget[];
63
+ }): boolean;
64
+ export declare function matches(match: readonly MatchCondition[], call: {
65
+ params: unknown;
66
+ targets: readonly ResolvedTarget[];
67
+ }): boolean;
@@ -0,0 +1,133 @@
1
+ import { MATCH_OPS } from '@synoikia/plugin-sdk';
2
+ import { z } from 'zod';
3
+ import { canonicalJson } from './canonical.js';
4
+ /**
5
+ * Pre-approval `match` evaluator (design §5.2). A rule's match is a list of conditions that must all
6
+ * hold. It fails closed: a missing field, a type mismatch or zero resolved targets means "no match",
7
+ * which only ever sends the call to a human.
8
+ *
9
+ * Matching is **strict**: every parameter of the call must be covered by a condition. A parameter the
10
+ * rule doesn't mention must be absent, unless the rule accepts it with `{ field, op: 'any' }` (`field: ''`
11
+ * accepts all parameters). Otherwise a rule for one field would also approve whatever else the model
12
+ * chose to send (a quota, an ACL, encryption options).
13
+ */
14
+ export const PARAM_OPS = [...MATCH_OPS, 'any'];
15
+ export const ParamConditionSchema = z
16
+ .object({
17
+ field: z.string().regex(/^(\/.*)?$/, 'must be a JSON pointer'),
18
+ op: z.enum(PARAM_OPS),
19
+ value: z.unknown().optional(),
20
+ })
21
+ .refine((c) => c.op === 'any' || c.field !== '', 'only "any" can apply to all parameters');
22
+ export const TargetConditionSchema = z
23
+ .object({
24
+ field: z.literal('$targets'),
25
+ areas: z.array(z.string()).optional(),
26
+ entities: z.array(z.string()).optional(),
27
+ domains: z.array(z.string()).optional(),
28
+ })
29
+ .refine((c) => c.areas?.length || c.entities?.length || c.domains?.length, 'select at least one area, entity or domain');
30
+ export const MatchSchema = z.array(z.union([TargetConditionSchema, ParamConditionSchema]));
31
+ const MISSING = Symbol('missing');
32
+ /** RFC 6901 JSON pointer lookup; returns MISSING instead of undefined so `null` values still count. */
33
+ export function getPointer(doc, pointer) {
34
+ if (pointer === '')
35
+ return doc;
36
+ let cur = doc;
37
+ for (const raw of pointer.slice(1).split('/')) {
38
+ const key = raw.replace(/~1/g, '/').replace(/~0/g, '~');
39
+ if (cur === null || typeof cur !== 'object' || !Object.prototype.hasOwnProperty.call(cur, key))
40
+ return MISSING;
41
+ cur = cur[key];
42
+ }
43
+ return cur;
44
+ }
45
+ function paramMatches(actual, op, expected) {
46
+ if (op === 'any')
47
+ return true; // present or not, any value
48
+ if (actual === MISSING)
49
+ return false;
50
+ switch (op) {
51
+ case 'eq':
52
+ return canonicalJson(actual) === canonicalJson(expected);
53
+ case 'in': {
54
+ if (!Array.isArray(expected))
55
+ return false;
56
+ const allowed = new Set(expected.map((v) => canonicalJson(v)));
57
+ const values = Array.isArray(actual) ? actual : [actual];
58
+ return values.length > 0 && values.every((v) => allowed.has(canonicalJson(v)));
59
+ }
60
+ case 'prefix':
61
+ // Path-segment boundary: "tank/media" matches "tank/media" and "tank/media/tv", not "tank/media-private".
62
+ return (typeof actual === 'string' &&
63
+ typeof expected === 'string' &&
64
+ expected !== '' &&
65
+ (actual === expected ||
66
+ (actual.startsWith(expected) && (expected.endsWith('/') || actual[expected.length] === '/'))));
67
+ case 'range': {
68
+ const { min, max } = (expected ?? {});
69
+ if (typeof actual !== 'number' || !Number.isFinite(actual))
70
+ return false;
71
+ if (min === undefined && max === undefined)
72
+ return false;
73
+ if (min !== undefined && (typeof min !== 'number' || actual < min))
74
+ return false;
75
+ if (max !== undefined && (typeof max !== 'number' || actual > max))
76
+ return false;
77
+ return true;
78
+ }
79
+ case 'bool':
80
+ return typeof actual === 'boolean' && actual === expected;
81
+ default:
82
+ return false;
83
+ }
84
+ }
85
+ function targetMatches(target, c) {
86
+ if (c.areas?.length && !c.areas.includes(target.scopes?.area ?? ''))
87
+ return false;
88
+ if (c.entities?.length && !c.entities.includes(target.id))
89
+ return false;
90
+ if (c.domains?.length && !c.domains.includes(target.scopes?.domain ?? ''))
91
+ return false;
92
+ return true;
93
+ }
94
+ const escapePointer = (key) => key.replace(/~/g, '~0').replace(/\//g, '~1');
95
+ /**
96
+ * Whether every parameter is covered by some condition: a condition on a path covers everything under
97
+ * it; an object or array only partly covered has each of its keys (or indexes: `/0/name`) checked in
98
+ * turn. Nothing (or `{}` / `[]`) is covered. Arrays matter for positional APIs such as TrueNAS, whose
99
+ * params are `[{ name, … }]`.
100
+ */
101
+ export function coversAllParams(match, params) {
102
+ const pointers = match.filter((c) => c.field !== '$targets').map((c) => c.field);
103
+ const covered = (value, path) => {
104
+ if (pointers.includes(path))
105
+ return true;
106
+ if (value === undefined || value === null)
107
+ return path === '';
108
+ if (typeof value !== 'object')
109
+ return false;
110
+ const entries = Array.isArray(value) ? value.map((v, i) => [String(i), v]) : Object.entries(value);
111
+ if (entries.length === 0)
112
+ return true;
113
+ if (!pointers.some((p) => p.startsWith(`${path}/`)))
114
+ return false;
115
+ return entries.every(([k, v]) => covered(v, `${path}/${escapePointer(k)}`));
116
+ };
117
+ return covered(params, '');
118
+ }
119
+ /** The conditions alone, without the strict every-parameter check (used to spot rules strictness broke). */
120
+ export function conditionsHold(match, call) {
121
+ return match.every((c) => {
122
+ if (c.field === '$targets') {
123
+ const tc = c;
124
+ return call.targets.length > 0 && call.targets.every((t) => targetMatches(t, tc));
125
+ }
126
+ const pc = c;
127
+ return paramMatches(getPointer(call.params, pc.field), pc.op, pc.value);
128
+ });
129
+ }
130
+ export function matches(match, call) {
131
+ return conditionsHold(match, call) && coversAllParams(match, call.params);
132
+ }
133
+ //# sourceMappingURL=match.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"match.js","sourceRoot":"","sources":["../../src/gate/match.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,61 @@
1
+ import type { Manifest } from '@synoikia/plugin-sdk';
2
+ import type { ApprovalService, ClientPrompts } from '../approvals/service.js';
3
+ import type { Db } from '../db/index.js';
4
+ import type { AccessPrincipal } from './access.js';
5
+ import type { InstanceSettings } from '../instances/settings.js';
6
+ import type { PluginProcess } from '../plugins/process.js';
7
+ import type { Binding } from '../sandbox/index.js';
8
+ import type { SlidingWindowLimiter } from './rate-limit.js';
9
+ import type { Redactor } from './redact.js';
10
+ /**
11
+ * The permission gate (design §5.2): the only path from sandboxed code to a plugin's `invoke`.
12
+ *
13
+ * resolveOperation → attestation → access level → resolveTargets → prepareWrite
14
+ * → [level ask: pre-approval rule (never for locked) → human approval] → invoke → redact → audit
15
+ *
16
+ * Reads run straight away; acknowledged writes at level `write` are auto-approved.
17
+ *
18
+ * Every branch, including every rejection, writes one `call` audit event. Calls within one
19
+ * `execute` run strictly one at a time, so a pending approval blocks the whole script (TN §3.1).
20
+ */
21
+ export interface GateDeps {
22
+ db: Db;
23
+ approvals: ApprovalService;
24
+ limiter: SlidingWindowLimiter;
25
+ attestationKey: Buffer;
26
+ now?: () => Date;
27
+ }
28
+ export interface InstanceRuntime {
29
+ instanceId: string;
30
+ slug: string;
31
+ manifest: Manifest;
32
+ settings: InstanceSettings;
33
+ redact: Redactor;
34
+ /** The live plugin process; throws PluginUnavailableError while it is down. */
35
+ plugin: () => PluginProcess;
36
+ }
37
+ export interface CallerContext {
38
+ /**
39
+ * `id` is the readable label recorded in the audit log (`token:Claude`); `key` is the stable
40
+ * principal (token id, grant id, external identity) that per-principal limits are keyed on, since
41
+ * two tokens may share a name.
42
+ */
43
+ client: {
44
+ kind: 'mcp_client';
45
+ id?: string;
46
+ key?: string;
47
+ };
48
+ mcpSessionId?: string;
49
+ /** The authenticated principal's access ceiling (consent page / bearer token). */
50
+ principal: AccessPrincipal;
51
+ /** Present only for a live session whose client advertised elicitation. */
52
+ prompts?: ClientPrompts;
53
+ }
54
+ /** What per-principal limits are keyed on: the stable principal, never the display name. */
55
+ export declare const principalKey: (caller: CallerContext) => string;
56
+ /**
57
+ * The gate bindings for one `execute` run. `signal` ends the run: once it is aborted (the sandbox
58
+ * settled, the MCP request was cancelled, or the session closed), calls the script left behind are
59
+ * refused and an approval still open is cancelled, so nothing runs after the tool call has returned.
60
+ */
61
+ export declare function createGateBindings(deps: GateDeps, rt: InstanceRuntime, caller: CallerContext, signal?: AbortSignal): Record<string, Record<string, Binding>>;
@@ -0,0 +1,284 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { and, eq } from 'drizzle-orm';
3
+ import { writeAudit } from '../audit.js';
4
+ import { resolveAccess } from '../catalog/groups.js';
5
+ import { operations } from '../db/schema.js';
6
+ import { PluginProtocolError, PluginRpcError, PluginTimeoutError, PluginUnavailableError } from '../plugins/process.js';
7
+ import { BindingError } from '../sandbox/index.js';
8
+ import { verifyAttestationKey } from './attestation.js';
9
+ import { canonicalJson, sha256Hex } from './canonical.js';
10
+ import { evaluatePreApproval } from './preapproval.js';
11
+ /** What per-principal limits are keyed on: the stable principal, never the display name. */
12
+ export const principalKey = (caller) => caller.client.key ?? caller.client.id ?? 'anonymous';
13
+ const MAX_ERROR_MESSAGE = 500;
14
+ const PASSTHROUGH_PLUGIN_CODES = new Set([
15
+ 'UNKNOWN_OPERATION',
16
+ 'INVALID_PARAMS',
17
+ 'TARGET_RESOLUTION_FAILED',
18
+ 'CONFIG_CONFLICT',
19
+ 'UPSTREAM_DENIED',
20
+ 'UPSTREAM_ERROR',
21
+ ]);
22
+ function toBindingError(err) {
23
+ if (err instanceof BindingError)
24
+ return err;
25
+ if (err instanceof PluginRpcError) {
26
+ const code = PASSTHROUGH_PLUGIN_CODES.has(err.code) ? err.code : 'PLUGIN_ERROR';
27
+ return new BindingError(code, err.message.slice(0, MAX_ERROR_MESSAGE));
28
+ }
29
+ if (err instanceof PluginUnavailableError)
30
+ return new BindingError('PLUGIN_UNAVAILABLE', 'The plugin is not running; try again shortly');
31
+ if (err instanceof PluginTimeoutError)
32
+ return new BindingError('UPSTREAM_TIMEOUT', err.message);
33
+ if (err instanceof PluginProtocolError)
34
+ return new BindingError('PLUGIN_ERROR', 'The plugin returned an invalid response');
35
+ return new BindingError('INTERNAL', 'Internal error');
36
+ }
37
+ const DISABLED_MESSAGES = {
38
+ group_missing: 'is not available on this endpoint',
39
+ level_none: 'is disabled on this endpoint (access level None)',
40
+ read_only: 'is a write, and its access level on this endpoint is Read',
41
+ token_read_only: 'is a write, and this connection was granted read-only access',
42
+ locked_not_opted_in: 'is a protected operation that the administrator has not enabled',
43
+ unknown_operation: 'is not in the current catalog',
44
+ };
45
+ /** Order-independent identity of a resolved target set. */
46
+ const targetKey = (targets) => JSON.stringify(targets.map((t) => `${t.kind}:${t.id}`).sort());
47
+ const DENIAL_MESSAGES = {
48
+ no_approval_path: 'needs approval, and this MCP client cannot show approval prompts',
49
+ client_cannot_approve: 'needs approval on a web page, and this MCP client only supports form prompts; use a client that supports URL elicitation',
50
+ declined: 'was declined in the client',
51
+ prompt_failed: 'needs approval, but the client could not show the approval prompt',
52
+ confirmation_mismatch: 'was denied (confirmation mismatch)',
53
+ endpoint_stopped: 'was cancelled: the endpoint was stopped or reconfigured while it waited for approval',
54
+ };
55
+ /**
56
+ * The gate bindings for one `execute` run. `signal` ends the run: once it is aborted (the sandbox
57
+ * settled, the MCP request was cancelled, or the session closed), calls the script left behind are
58
+ * refused and an approval still open is cancelled, so nothing runs after the tool call has returned.
59
+ */
60
+ export function createGateBindings(deps, rt, caller, signal = new AbortController().signal) {
61
+ const now = deps.now ?? (() => new Date());
62
+ const executionId = randomUUID();
63
+ let queue = Promise.resolve();
64
+ const openApprovals = new Set();
65
+ signal.addEventListener('abort', () => {
66
+ for (const id of openApprovals)
67
+ deps.approvals.cancel(id, 'execution_ended');
68
+ });
69
+ const serialized = (fn) => (args, budget) => {
70
+ const run = queue.then(() => gatedCall(fn, args, budget));
71
+ queue = run.catch(() => undefined);
72
+ return run;
73
+ };
74
+ async function gatedCall(fn, args, budget) {
75
+ const started = Date.now();
76
+ const audit = {
77
+ kind: 'call',
78
+ instanceId: rt.instanceId,
79
+ actorKind: 'mcp_client',
80
+ actorId: caller.client.id,
81
+ detail: { executionId, fn },
82
+ };
83
+ let audited = false;
84
+ const finish = (decision, extra = {}) => {
85
+ audited = true;
86
+ writeAudit(deps.db, {
87
+ ...audit,
88
+ decision,
89
+ resultStatus: extra.resultStatus ?? null,
90
+ durationMs: Date.now() - started,
91
+ detail: { ...audit.detail, ...extra.detail },
92
+ }, now());
93
+ };
94
+ const reject = (decision, err) => {
95
+ finish(decision, { resultStatus: 'rejected', detail: { error: err.code } });
96
+ throw err;
97
+ };
98
+ const ensureRunning = () => {
99
+ if (signal.aborted)
100
+ reject('rejected:execution_ended', new BindingError('EXECUTION_ENDED', 'The execute call this belongs to has already ended'));
101
+ };
102
+ try {
103
+ ensureRunning();
104
+ // 0. Map the raw binding call onto a catalog key (Seerr path templates, HA garage-door split…).
105
+ const resolved = await rt.plugin().call('resolveOperation', { fn, args });
106
+ audit.operationKey = resolved.key;
107
+ const op = deps.db
108
+ .select()
109
+ .from(operations)
110
+ .where(and(eq(operations.instanceId, rt.instanceId), eq(operations.key, resolved.key)))
111
+ .get();
112
+ audit.params = rt.redact(resolved.params);
113
+ // 1. Attestation, before anything else about the op is revealed (HA §3.6).
114
+ if (op && !op.stale && op.attestationRequired) {
115
+ const presented = resolved.params?.best_practice_key;
116
+ if (!verifyAttestationKey(deps.db, deps.attestationKey, {
117
+ instanceId: rt.instanceId,
118
+ operationId: op.id,
119
+ opKey: op.key,
120
+ mcpSessionId: caller.mcpSessionId,
121
+ presented,
122
+ })) {
123
+ reject('rejected:attestation_required', new BindingError('ATTESTATION_REQUIRED', `Call search() for the ${op.key} guide first and pass its best_practice_key`));
124
+ }
125
+ }
126
+ // 2. Access level (operation's own, else its group's) under the principal's ceiling. Unknown or
127
+ // stale ops fail here too.
128
+ const access = resolveAccess(deps.db, rt.instanceId, resolved.key, caller.principal);
129
+ if (!access.reachable || !op) {
130
+ const reason = access.reachable ? 'unknown_operation' : access.reason;
131
+ reject(`rejected:${reason}`, Object.assign(new BindingError('OPERATION_DISABLED', `${resolved.key} ${DISABLED_MESSAGES[reason] ?? 'is not available'}`), { reason }));
132
+ }
133
+ const operation = op;
134
+ const mode = access.reachable ? access.mode : 'run';
135
+ const isWrite = mode !== 'run';
136
+ audit.classification = operation.locked ? 'locked' : operation.classification;
137
+ // 3. Concrete targets, so rules and approvers see exactly what will be touched. Fails closed.
138
+ let targets = [];
139
+ if (rt.manifest.capabilities.targets) {
140
+ targets = await rt.plugin().call('resolveTargets', { key: operation.key, params: resolved.params });
141
+ audit.resolvedTargets = targets;
142
+ }
143
+ // 4. Config transforms: compute the real diff against the live object (HA §2.8).
144
+ let params = resolved.params;
145
+ let diff;
146
+ let expectedHash;
147
+ if (isWrite && rt.manifest.capabilities.configTransform && operation.kind === 'config') {
148
+ const prepared = await rt.plugin().call('prepareWrite', { key: operation.key, params });
149
+ params = prepared.params;
150
+ diff = prepared.diff;
151
+ expectedHash = prepared.expectedHash;
152
+ }
153
+ // 5. Reads run straight away.
154
+ let decision = 'auto-executed';
155
+ let approval;
156
+ // Each principal has its own write budget, charged only for writes that actually run (step 9):
157
+ // a noisy, denied or timed-out client doesn't use up anyone else's.
158
+ const writeBucket = `write:${rt.instanceId}:${principalKey(caller)}`;
159
+ const overWriteBudget = () => reject('rejected:rate_limited', new BindingError('RATE_LIMITED', 'Too many write calls; slow down'));
160
+ if (isWrite) {
161
+ // Checked before asking anyone, so nobody approves a call that would be refused anyway.
162
+ if (!deps.limiter.allows(writeBucket, rt.settings.writesPerMinute, 60_000))
163
+ overWriteBudget();
164
+ // 6. Level `write`: acknowledged writes are auto-approved. Level `ask`: pre-approval rules, which
165
+ // never cover locked ops (TN §3.4) nor writes still waiting for acknowledgement.
166
+ let preapproved = false;
167
+ if (mode === 'auto') {
168
+ preapproved = true;
169
+ decision = 'auto-approved:level';
170
+ }
171
+ else if (!operation.locked && access.reachable && access.level === 'ask') {
172
+ const outcome = evaluatePreApproval(deps.db, { instanceId: rt.instanceId, operationId: operation.id, params, targets }, now());
173
+ if (outcome.kind === 'auto_approved') {
174
+ preapproved = true;
175
+ decision = `auto-approved:rule:${outcome.ruleId}`;
176
+ }
177
+ else if (outcome.kind === 'rate_limited') {
178
+ audit.detail.rateLimitedRules = outcome.ruleIds;
179
+ }
180
+ }
181
+ // 7. Human approval, scoped to exactly these params and targets.
182
+ if (!preapproved) {
183
+ // The plugin only sees redacted params here: summaries and confirmation literals end up in
184
+ // prompts, the DB and notifications, and never need a secret.
185
+ const summary = await rt
186
+ .plugin()
187
+ .call('summarize', { key: operation.key, params: rt.redact(params), targets });
188
+ if (operation.typedConfirmation && !summary.confirmLiteral) {
189
+ reject('rejected:missing_confirmation_literal', new BindingError('PLUGIN_ERROR', `The plugin did not provide a confirmation value for ${operation.key}`));
190
+ }
191
+ ensureRunning();
192
+ const paramsHash = sha256Hex(canonicalJson({ key: operation.key, params, targets, expectedHash: expectedHash ?? null }));
193
+ const request = deps.approvals.request({
194
+ instanceId: rt.instanceId,
195
+ operationId: operation.id,
196
+ operationKey: operation.key,
197
+ classification: audit.classification,
198
+ paramsDisplay: rt.redact(params),
199
+ paramsHash,
200
+ resolvedTargets: targets,
201
+ summary: summary.text,
202
+ confirmLiteral: operation.typedConfirmation ? summary.confirmLiteral : undefined,
203
+ diff,
204
+ expectedHash,
205
+ client: caller.client,
206
+ mcpSessionId: caller.mcpSessionId,
207
+ timeoutMs: rt.settings.approvalTimeoutMs,
208
+ prompts: caller.prompts,
209
+ formApprovals: rt.settings.formElicitationApprovals === 'writes' && !operation.locked && !operation.typedConfirmation,
210
+ });
211
+ audit.detail.approvalId = request.id;
212
+ openApprovals.add(request.id);
213
+ budget.pause();
214
+ try {
215
+ approval = await request.decision;
216
+ }
217
+ finally {
218
+ openApprovals.delete(request.id);
219
+ budget.resume();
220
+ }
221
+ audit.decidedBy = approval.decidedBy;
222
+ audit.decidedVia = approval.via;
223
+ if (approval.outcome === 'cancelled' && signal.aborted)
224
+ ensureRunning();
225
+ if (approval.outcome !== 'approved') {
226
+ const label = approval.outcome === 'timed_out' ? 'timed-out' : 'denied';
227
+ reject(label, new BindingError('PERMISSION_DENIED', approval.outcome === 'timed_out'
228
+ ? `Approval for ${operation.key} timed out and was denied`
229
+ : `${operation.key} ${(approval.reason && DENIAL_MESSAGES[approval.reason]) ?? `was denied${approval.reason ? ` (${approval.reason})` : ''}`}`));
230
+ }
231
+ // The admin may have lowered the level while the approval was open: the call must still be allowed.
232
+ const after = resolveAccess(deps.db, rt.instanceId, operation.key, caller.principal);
233
+ if (!after.reachable || after.mode === 'run') {
234
+ reject('rejected:access_changed', new BindingError('OPERATION_DISABLED', `${operation.key} was disabled while waiting for approval`));
235
+ }
236
+ decision = 'human-approved';
237
+ }
238
+ }
239
+ // 8. The approver saw these targets; after a wait they may have moved (an entity joined the area).
240
+ // Resolve again and refuse unless the set is unchanged, then hand the plugin the approved set.
241
+ ensureRunning();
242
+ if (decision === 'human-approved' && rt.manifest.capabilities.targets) {
243
+ const now = await rt.plugin().call('resolveTargets', { key: operation.key, params });
244
+ if (targetKey(now) !== targetKey(targets)) {
245
+ audit.detail.targetsNow = now;
246
+ reject('rejected:targets_changed', new BindingError('TARGETS_CHANGED', `What ${operation.key} would act on changed while it waited for approval; call it again to get a fresh approval`));
247
+ }
248
+ }
249
+ // 9. The real upstream call, within what's left of the sandbox budget.
250
+ if (isWrite && !deps.limiter.take(writeBucket, rt.settings.writesPerMinute, 60_000))
251
+ overWriteBudget();
252
+ const remaining = Math.max(1, budget.remainingMs());
253
+ const result = await rt.plugin().call('invoke', {
254
+ key: operation.key,
255
+ params,
256
+ context: {
257
+ callId: randomUUID(),
258
+ expectedHash,
259
+ deadlineMs: remaining,
260
+ ...(rt.manifest.capabilities.targets ? { targets } : {}),
261
+ },
262
+ }, remaining);
263
+ // 10–11. Redact, audit, hand back.
264
+ finish(decision, { resultStatus: 'ok' });
265
+ return rt.redact(result);
266
+ }
267
+ catch (err) {
268
+ const raw = toBindingError(err);
269
+ // Plugin and upstream messages are free text: scrub the instance's secret values out of them.
270
+ const mapped = new BindingError(raw.code, rt.redact(raw.message));
271
+ Object.assign(mapped, { reason: raw.reason });
272
+ // Gate rejections were audited where they were raised; plugin/upstream failures are audited here.
273
+ if (!audited) {
274
+ const decision = audit.classification ? `error:${mapped.code}` : `rejected:${mapped.code.toLowerCase()}`;
275
+ finish(decision, { resultStatus: 'error', detail: { error: mapped.code } });
276
+ }
277
+ throw mapped;
278
+ }
279
+ }
280
+ return {
281
+ [rt.manifest.binding.namespace]: Object.fromEntries(rt.manifest.binding.functions.map((fn) => [fn, serialized(fn)])),
282
+ };
283
+ }
284
+ //# sourceMappingURL=pipeline.js.map