cedar-mcp-server 1.0.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 (215) hide show
  1. package/.editorconfig +12 -0
  2. package/.github/workflows/ci.yml +31 -0
  3. package/.github/workflows/release.yml +42 -0
  4. package/.nvmrc +1 -0
  5. package/CHANGELOG.md +241 -0
  6. package/CONTRIBUTING.md +83 -0
  7. package/LICENSE +182 -0
  8. package/README.md +1635 -0
  9. package/SECURITY.md +37 -0
  10. package/dist/http-server.d.ts +61 -0
  11. package/dist/http-server.d.ts.map +1 -0
  12. package/dist/http-server.js +194 -0
  13. package/dist/http-server.js.map +1 -0
  14. package/dist/index.d.ts +32 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +270 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/parser/policy-ast.d.ts +49 -0
  19. package/dist/parser/policy-ast.d.ts.map +1 -0
  20. package/dist/parser/policy-ast.js +311 -0
  21. package/dist/parser/policy-ast.js.map +1 -0
  22. package/dist/prompts/index.d.ts +38 -0
  23. package/dist/prompts/index.d.ts.map +1 -0
  24. package/dist/prompts/index.js +172 -0
  25. package/dist/prompts/index.js.map +1 -0
  26. package/dist/resources/ref-resolver.d.ts +23 -0
  27. package/dist/resources/ref-resolver.d.ts.map +1 -0
  28. package/dist/resources/ref-resolver.js +128 -0
  29. package/dist/resources/ref-resolver.js.map +1 -0
  30. package/dist/resources/store-manager.d.ts +64 -0
  31. package/dist/resources/store-manager.d.ts.map +1 -0
  32. package/dist/resources/store-manager.js +221 -0
  33. package/dist/resources/store-manager.js.map +1 -0
  34. package/dist/server.d.ts +18 -0
  35. package/dist/server.d.ts.map +1 -0
  36. package/dist/server.js +539 -0
  37. package/dist/server.js.map +1 -0
  38. package/dist/tools/advise/avp-rules.d.ts +49 -0
  39. package/dist/tools/advise/avp-rules.d.ts.map +1 -0
  40. package/dist/tools/advise/avp-rules.js +59 -0
  41. package/dist/tools/advise/avp-rules.js.map +1 -0
  42. package/dist/tools/advise/cedar-patterns.d.ts +24 -0
  43. package/dist/tools/advise/cedar-patterns.d.ts.map +1 -0
  44. package/dist/tools/advise/cedar-patterns.js +57 -0
  45. package/dist/tools/advise/cedar-patterns.js.map +1 -0
  46. package/dist/tools/advise/context-builder.d.ts +28 -0
  47. package/dist/tools/advise/context-builder.d.ts.map +1 -0
  48. package/dist/tools/advise/context-builder.js +89 -0
  49. package/dist/tools/advise/context-builder.js.map +1 -0
  50. package/dist/tools/advise/gotchas.d.ts +15 -0
  51. package/dist/tools/advise/gotchas.d.ts.map +1 -0
  52. package/dist/tools/advise/gotchas.js +83 -0
  53. package/dist/tools/advise/gotchas.js.map +1 -0
  54. package/dist/tools/advise.d.ts +96 -0
  55. package/dist/tools/advise.d.ts.map +1 -0
  56. package/dist/tools/advise.js +258 -0
  57. package/dist/tools/advise.js.map +1 -0
  58. package/dist/tools/authorize-batch.d.ts +35 -0
  59. package/dist/tools/authorize-batch.d.ts.map +1 -0
  60. package/dist/tools/authorize-batch.js +262 -0
  61. package/dist/tools/authorize-batch.js.map +1 -0
  62. package/dist/tools/authorize.d.ts +115 -0
  63. package/dist/tools/authorize.d.ts.map +1 -0
  64. package/dist/tools/authorize.js +373 -0
  65. package/dist/tools/authorize.js.map +1 -0
  66. package/dist/tools/check-change.d.ts +19 -0
  67. package/dist/tools/check-change.d.ts.map +1 -0
  68. package/dist/tools/check-change.js +91 -0
  69. package/dist/tools/check-change.js.map +1 -0
  70. package/dist/tools/diff-schema.d.ts +103 -0
  71. package/dist/tools/diff-schema.d.ts.map +1 -0
  72. package/dist/tools/diff-schema.js +379 -0
  73. package/dist/tools/diff-schema.js.map +1 -0
  74. package/dist/tools/diff-stores.d.ts +45 -0
  75. package/dist/tools/diff-stores.d.ts.map +1 -0
  76. package/dist/tools/diff-stores.js +222 -0
  77. package/dist/tools/diff-stores.js.map +1 -0
  78. package/dist/tools/explain.d.ts +80 -0
  79. package/dist/tools/explain.d.ts.map +1 -0
  80. package/dist/tools/explain.js +187 -0
  81. package/dist/tools/explain.js.map +1 -0
  82. package/dist/tools/format.d.ts +11 -0
  83. package/dist/tools/format.d.ts.map +1 -0
  84. package/dist/tools/format.js +20 -0
  85. package/dist/tools/format.js.map +1 -0
  86. package/dist/tools/generate-sample.d.ts +28 -0
  87. package/dist/tools/generate-sample.d.ts.map +1 -0
  88. package/dist/tools/generate-sample.js +568 -0
  89. package/dist/tools/generate-sample.js.map +1 -0
  90. package/dist/tools/link-template.d.ts +17 -0
  91. package/dist/tools/link-template.d.ts.map +1 -0
  92. package/dist/tools/link-template.js +78 -0
  93. package/dist/tools/link-template.js.map +1 -0
  94. package/dist/tools/list-template-links.d.ts +16 -0
  95. package/dist/tools/list-template-links.d.ts.map +1 -0
  96. package/dist/tools/list-template-links.js +22 -0
  97. package/dist/tools/list-template-links.js.map +1 -0
  98. package/dist/tools/list-templates.d.ts +16 -0
  99. package/dist/tools/list-templates.d.ts.map +1 -0
  100. package/dist/tools/list-templates.js +36 -0
  101. package/dist/tools/list-templates.js.map +1 -0
  102. package/dist/tools/translate.d.ts +11 -0
  103. package/dist/tools/translate.d.ts.map +1 -0
  104. package/dist/tools/translate.js +53 -0
  105. package/dist/tools/translate.js.map +1 -0
  106. package/dist/tools/validate-entities.d.ts +19 -0
  107. package/dist/tools/validate-entities.d.ts.map +1 -0
  108. package/dist/tools/validate-entities.js +88 -0
  109. package/dist/tools/validate-entities.js.map +1 -0
  110. package/dist/tools/validate-schema.d.ts +22 -0
  111. package/dist/tools/validate-schema.d.ts.map +1 -0
  112. package/dist/tools/validate-schema.js +89 -0
  113. package/dist/tools/validate-schema.js.map +1 -0
  114. package/dist/tools/validate-template.d.ts +18 -0
  115. package/dist/tools/validate-template.d.ts.map +1 -0
  116. package/dist/tools/validate-template.js +59 -0
  117. package/dist/tools/validate-template.js.map +1 -0
  118. package/dist/tools/validate.d.ts +90 -0
  119. package/dist/tools/validate.d.ts.map +1 -0
  120. package/dist/tools/validate.js +351 -0
  121. package/dist/tools/validate.js.map +1 -0
  122. package/dist/utils/format-detector.d.ts +49 -0
  123. package/dist/utils/format-detector.d.ts.map +1 -0
  124. package/dist/utils/format-detector.js +298 -0
  125. package/dist/utils/format-detector.js.map +1 -0
  126. package/examples/README.md +36 -0
  127. package/examples/abac-multi-tenant/README.md +150 -0
  128. package/examples/abac-multi-tenant/entities/users-and-docs.json +33 -0
  129. package/examples/abac-multi-tenant/policies/member-read-internal.cedar +9 -0
  130. package/examples/abac-multi-tenant/policies/owner-full-access.cedar +9 -0
  131. package/examples/abac-multi-tenant/policies/premium-share-guard.cedar +9 -0
  132. package/examples/abac-multi-tenant/policies/private-doc-guard.cedar +13 -0
  133. package/examples/abac-multi-tenant/run.ts +92 -0
  134. package/examples/abac-multi-tenant/schema.json +60 -0
  135. package/examples/api-gateway-path-routing/README.md +154 -0
  136. package/examples/api-gateway-path-routing/entities/users-and-roles.json +20 -0
  137. package/examples/api-gateway-path-routing/policies/admin-full-access.cedar +6 -0
  138. package/examples/api-gateway-path-routing/policies/developer-projects.cedar +14 -0
  139. package/examples/api-gateway-path-routing/policies/viewer-readonly.cedar +10 -0
  140. package/examples/api-gateway-path-routing/run.ts +108 -0
  141. package/examples/api-gateway-path-routing/schema.json +54 -0
  142. package/examples/rbac-document-management/README.md +167 -0
  143. package/examples/rbac-document-management/entities/users-and-docs.json +43 -0
  144. package/examples/rbac-document-management/policies/admin.cedar +6 -0
  145. package/examples/rbac-document-management/policies/editor.cedar +6 -0
  146. package/examples/rbac-document-management/policies/top-secret-forbid.cedar +13 -0
  147. package/examples/rbac-document-management/policies/viewer.cedar +6 -0
  148. package/examples/rbac-document-management/run.ts +87 -0
  149. package/examples/rbac-document-management/schema.json +57 -0
  150. package/package.json +50 -0
  151. package/src/http-server.ts +239 -0
  152. package/src/index.ts +294 -0
  153. package/src/parser/policy-ast.ts +345 -0
  154. package/src/prompts/README.md +3 -0
  155. package/src/prompts/index.ts +217 -0
  156. package/src/resources/ref-resolver.ts +134 -0
  157. package/src/resources/store-manager.ts +248 -0
  158. package/src/server.ts +711 -0
  159. package/src/tools/advise/avp-rules.ts +70 -0
  160. package/src/tools/advise/cedar-patterns.ts +73 -0
  161. package/src/tools/advise/context-builder.ts +109 -0
  162. package/src/tools/advise/gotchas.ts +92 -0
  163. package/src/tools/advise.ts +366 -0
  164. package/src/tools/authorize-batch.ts +345 -0
  165. package/src/tools/authorize.ts +464 -0
  166. package/src/tools/check-change.ts +119 -0
  167. package/src/tools/diff-schema.ts +510 -0
  168. package/src/tools/diff-stores.ts +298 -0
  169. package/src/tools/explain.ts +278 -0
  170. package/src/tools/format.ts +33 -0
  171. package/src/tools/generate-sample.ts +665 -0
  172. package/src/tools/link-template.ts +109 -0
  173. package/src/tools/list-template-links.ts +41 -0
  174. package/src/tools/list-templates.ts +55 -0
  175. package/src/tools/translate.ts +66 -0
  176. package/src/tools/validate-entities.ts +125 -0
  177. package/src/tools/validate-schema.ts +128 -0
  178. package/src/tools/validate-template.ts +72 -0
  179. package/src/tools/validate.ts +459 -0
  180. package/src/utils/format-detector.ts +356 -0
  181. package/test/fixtures/docmgmt.ts +121 -0
  182. package/test/fixtures/multitenant.ts +163 -0
  183. package/test/index.test.ts +96 -0
  184. package/test/integration/e2e/behavior.test.ts +359 -0
  185. package/test/integration/e2e/edge-cases.test.ts +365 -0
  186. package/test/integration/e2e/failure-modes.test.ts +266 -0
  187. package/test/integration/e2e/protocol.test.ts +252 -0
  188. package/test/integration/http-smoke.test.ts +588 -0
  189. package/test/integration/smoke.test.ts +475 -0
  190. package/test/prompts/prompts.test.ts +173 -0
  191. package/test/property/properties.test.ts +234 -0
  192. package/test/resources/ref-resolver.test.ts +186 -0
  193. package/test/resources/store-manager.test.ts +344 -0
  194. package/test/setup.test.ts +7 -0
  195. package/test/tools/advise/avp-rules.test.ts +76 -0
  196. package/test/tools/advise.test.ts +339 -0
  197. package/test/tools/authorize-batch.test.ts +459 -0
  198. package/test/tools/authorize.test.ts +682 -0
  199. package/test/tools/check-change.test.ts +104 -0
  200. package/test/tools/cross-fixture.test.ts +170 -0
  201. package/test/tools/diff-schema.test.ts +355 -0
  202. package/test/tools/diff-stores.test.ts +291 -0
  203. package/test/tools/explain.test.ts +221 -0
  204. package/test/tools/format.test.ts +33 -0
  205. package/test/tools/generate-sample.test.ts +480 -0
  206. package/test/tools/link-template.test.ts +90 -0
  207. package/test/tools/list-templates.test.ts +151 -0
  208. package/test/tools/translate.test.ts +89 -0
  209. package/test/tools/validate-entities.test.ts +178 -0
  210. package/test/tools/validate-schema.test.ts +86 -0
  211. package/test/tools/validate-template.test.ts +89 -0
  212. package/test/tools/validate.test.ts +331 -0
  213. package/test/utils/format-detector.test.ts +518 -0
  214. package/tsconfig.json +17 -0
  215. package/vitest.config.ts +13 -0
@@ -0,0 +1,510 @@
1
+ import { schemaToText, schemaToJsonWithResolvedTypes } from "@cedar-policy/cedar-wasm/nodejs";
2
+ import type { Schema } from "@cedar-policy/cedar-wasm/nodejs";
3
+
4
+ export interface DiffSchemaInput {
5
+ blue: string;
6
+ green: string;
7
+ }
8
+
9
+ type Risk = "safe" | "review" | "breaking";
10
+
11
+ export interface AttributeChange {
12
+ attr: string;
13
+ change: "added" | "removed" | "type_changed" | "optional_to_required" | "required_to_optional";
14
+ old_type?: string;
15
+ new_type?: string;
16
+ risk: Risk;
17
+ reason: string;
18
+ }
19
+
20
+ export interface EntityTypeModification {
21
+ namespace: string;
22
+ name: string;
23
+ member_of_changes?: { added: string[]; removed: string[]; risk: Risk; reason: string };
24
+ attribute_changes?: AttributeChange[];
25
+ }
26
+
27
+ export interface ContextChange {
28
+ attr: string;
29
+ change: "added" | "removed" | "type_changed";
30
+ old_type?: string;
31
+ new_type?: string;
32
+ risk: Risk;
33
+ reason: string;
34
+ }
35
+
36
+ export interface ActionModification {
37
+ namespace: string;
38
+ name: string;
39
+ principal_types?: { added: string[]; removed: string[]; risk: Risk; reason: string };
40
+ resource_types?: { added: string[]; removed: string[]; risk: Risk; reason: string };
41
+ context_changes?: ContextChange[];
42
+ }
43
+
44
+ export interface SchemaDiff {
45
+ namespaces_added: string[];
46
+ namespaces_removed: string[];
47
+ entity_types: {
48
+ added: Array<{ namespace: string; name: string }>;
49
+ removed: Array<{ namespace: string; name: string; risk: Risk; reason: string }>;
50
+ modified: EntityTypeModification[];
51
+ };
52
+ actions: {
53
+ added: Array<{ namespace: string; name: string }>;
54
+ removed: Array<{ namespace: string; name: string; risk: Risk; reason: string }>;
55
+ modified: ActionModification[];
56
+ };
57
+ common_types: {
58
+ added: Array<{ namespace: string; name: string }>;
59
+ removed: Array<{ namespace: string; name: string; risk: Risk; reason: string }>;
60
+ modified: Array<{ namespace: string; name: string; risk: Risk; reason: string }>;
61
+ };
62
+ summary: string;
63
+ risk_level: Risk;
64
+ error?: string;
65
+ }
66
+
67
+ interface CanonicalSchema {
68
+ [ns: string]: {
69
+ entityTypes?: Record<string, CanonicalEntityType>;
70
+ actions?: Record<string, CanonicalAction>;
71
+ commonTypes?: Record<string, unknown>;
72
+ };
73
+ }
74
+
75
+ interface CanonicalEntityType {
76
+ memberOfTypes?: string[];
77
+ shape?: { type: string; attributes?: Record<string, CanonicalAttr> };
78
+ }
79
+
80
+ interface CanonicalAttr {
81
+ type: string;
82
+ required?: boolean;
83
+ }
84
+
85
+ interface CanonicalAction {
86
+ appliesTo?: {
87
+ principalTypes?: string[];
88
+ resourceTypes?: string[];
89
+ context?: { type: string; attributes?: Record<string, CanonicalAttr> };
90
+ };
91
+ }
92
+
93
+ function stripCedarPrefix(value: unknown): unknown {
94
+ if (typeof value === "string") return value.replace(/^__cedar::/, "");
95
+ if (Array.isArray(value)) return value.map(stripCedarPrefix);
96
+ if (value && typeof value === "object") {
97
+ const out: Record<string, unknown> = {};
98
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
99
+ out[k] = k === "type" && typeof v === "string" ? v.replace(/^__cedar::/, "") : stripCedarPrefix(v);
100
+ }
101
+ return out;
102
+ }
103
+ return value;
104
+ }
105
+
106
+ function normalizeToCanonical(schemaStr: string): CanonicalSchema {
107
+ let asText: string;
108
+
109
+ // 1. Detect JSON vs cedarschema text
110
+ let parsedJson: unknown = null;
111
+ try {
112
+ parsedJson = JSON.parse(schemaStr);
113
+ } catch {
114
+ parsedJson = null;
115
+ }
116
+
117
+ if (parsedJson !== null && typeof parsedJson === "object" && !Array.isArray(parsedJson)) {
118
+ const textAns = schemaToText(parsedJson as Schema);
119
+ if (textAns.type !== "success") {
120
+ throw new Error("Failed to convert JSON schema to text form: " + (textAns.errors?.[0]?.message ?? "unknown error"));
121
+ }
122
+ asText = textAns.text;
123
+ } else {
124
+ asText = schemaStr;
125
+ }
126
+
127
+ // 2. Always normalize via schemaToJsonWithResolvedTypes (text input only — per spike findings)
128
+ const ans = schemaToJsonWithResolvedTypes(asText);
129
+ if (ans.type !== "success") {
130
+ throw new Error("Failed to parse schema: " + (ans.errors?.[0]?.message ?? "unknown error"));
131
+ }
132
+
133
+ return stripCedarPrefix(ans.json) as CanonicalSchema;
134
+ }
135
+
136
+ function attrType(attr: CanonicalAttr | undefined): string {
137
+ if (!attr) return "unknown";
138
+ return attr.type;
139
+ }
140
+
141
+ function attrRequired(attr: CanonicalAttr): boolean {
142
+ return attr.required !== false;
143
+ }
144
+
145
+ function setDiff<T>(blue: T[], green: T[]): { added: T[]; removed: T[] } {
146
+ const blueSet = new Set(blue);
147
+ const greenSet = new Set(green);
148
+ return {
149
+ added: green.filter((x) => !blueSet.has(x)),
150
+ removed: blue.filter((x) => !greenSet.has(x)),
151
+ };
152
+ }
153
+
154
+ function diffAttributes(
155
+ blueAttrs: Record<string, CanonicalAttr> | undefined,
156
+ greenAttrs: Record<string, CanonicalAttr> | undefined,
157
+ contextLabel: "attribute" | "context attribute"
158
+ ): AttributeChange[] {
159
+ const b = blueAttrs ?? {};
160
+ const g = greenAttrs ?? {};
161
+ const changes: AttributeChange[] = [];
162
+
163
+ for (const [name, gAttr] of Object.entries(g)) {
164
+ if (!(name in b)) {
165
+ const required = attrRequired(gAttr);
166
+ changes.push({
167
+ attr: name,
168
+ change: "added",
169
+ new_type: attrType(gAttr),
170
+ risk: required ? "breaking" : "safe",
171
+ reason: required
172
+ ? `Required ${contextLabel} added: existing entities/requests without this field will fail validation.`
173
+ : `Optional ${contextLabel} added: existing policies do not reference it; safe to deploy.`,
174
+ });
175
+ }
176
+ }
177
+
178
+ for (const [name, bAttr] of Object.entries(b)) {
179
+ if (!(name in g)) {
180
+ changes.push({
181
+ attr: name,
182
+ change: "removed",
183
+ old_type: attrType(bAttr),
184
+ risk: "breaking",
185
+ reason: `${contextLabel[0].toUpperCase() + contextLabel.slice(1)} removed: policies referencing it will fail validation.`,
186
+ });
187
+ continue;
188
+ }
189
+ const gAttr = g[name];
190
+ if (attrType(bAttr) !== attrType(gAttr)) {
191
+ changes.push({
192
+ attr: name,
193
+ change: "type_changed",
194
+ old_type: attrType(bAttr),
195
+ new_type: attrType(gAttr),
196
+ risk: "breaking",
197
+ reason: `Type changed (${attrType(bAttr)} → ${attrType(gAttr)}): policies expecting the old type will fail evaluation.`,
198
+ });
199
+ continue;
200
+ }
201
+ const bReq = attrRequired(bAttr);
202
+ const gReq = attrRequired(gAttr);
203
+ if (bReq !== gReq) {
204
+ changes.push({
205
+ attr: name,
206
+ change: bReq ? "required_to_optional" : "optional_to_required",
207
+ risk: bReq ? "safe" : "breaking",
208
+ reason: bReq
209
+ ? "Attribute changed from required to optional; all existing entities still satisfy the constraint."
210
+ : "Attribute changed from optional to required; existing entities without this field will fail validation.",
211
+ });
212
+ }
213
+ }
214
+
215
+ return changes;
216
+ }
217
+
218
+ function diffEntityTypes(
219
+ blue: CanonicalSchema,
220
+ green: CanonicalSchema,
221
+ diff: SchemaDiff,
222
+ removedNamespaces: Set<string>,
223
+ addedNamespaces: Set<string>
224
+ ): void {
225
+ const allNamespaces = new Set([...Object.keys(blue), ...Object.keys(green)]);
226
+ for (const ns of allNamespaces) {
227
+ const bEnts = blue[ns]?.entityTypes ?? {};
228
+ const gEnts = green[ns]?.entityTypes ?? {};
229
+
230
+ for (const [name, gEnt] of Object.entries(gEnts)) {
231
+ if (!(name in bEnts)) {
232
+ diff.entity_types.added.push({ namespace: ns, name });
233
+ } else {
234
+ const bEnt = bEnts[name];
235
+ const mod: EntityTypeModification = { namespace: ns, name };
236
+
237
+ const bMember = bEnt.memberOfTypes ?? [];
238
+ const gMember = gEnt.memberOfTypes ?? [];
239
+ const memberD = setDiff(bMember, gMember);
240
+ if (memberD.added.length > 0 || memberD.removed.length > 0) {
241
+ const breaking = memberD.removed.length > 0;
242
+ mod.member_of_changes = {
243
+ added: memberD.added,
244
+ removed: memberD.removed,
245
+ risk: breaking ? "breaking" : "review",
246
+ reason: breaking
247
+ ? "Parent types removed: policies using `in` against removed parents will fail validation."
248
+ : "Parent types added: hierarchy widened; policies using `in` may match more entities than before.",
249
+ };
250
+ }
251
+
252
+ const attrChanges = diffAttributes(
253
+ bEnt.shape?.attributes,
254
+ gEnt.shape?.attributes,
255
+ "attribute"
256
+ );
257
+ if (attrChanges.length > 0) mod.attribute_changes = attrChanges;
258
+
259
+ if (mod.member_of_changes || mod.attribute_changes) {
260
+ diff.entity_types.modified.push(mod);
261
+ }
262
+ }
263
+ }
264
+
265
+ for (const [name, bEnt] of Object.entries(bEnts)) {
266
+ if (!(name in gEnts)) {
267
+ diff.entity_types.removed.push({
268
+ namespace: ns,
269
+ name,
270
+ risk: "breaking",
271
+ reason: removedNamespaces.has(ns)
272
+ ? `Namespace ${ns} removed; entity type removed transitively. Policies referencing it will fail.`
273
+ : "Entity type removed: policies referencing it will fail validation; runtime requests for it will fail.",
274
+ });
275
+ void bEnt;
276
+ }
277
+ }
278
+ void addedNamespaces;
279
+ }
280
+ }
281
+
282
+ function diffActions(
283
+ blue: CanonicalSchema,
284
+ green: CanonicalSchema,
285
+ diff: SchemaDiff
286
+ ): void {
287
+ const allNamespaces = new Set([...Object.keys(blue), ...Object.keys(green)]);
288
+ for (const ns of allNamespaces) {
289
+ const bActs = blue[ns]?.actions ?? {};
290
+ const gActs = green[ns]?.actions ?? {};
291
+
292
+ for (const [name, gAct] of Object.entries(gActs)) {
293
+ if (!(name in bActs)) {
294
+ diff.actions.added.push({ namespace: ns, name });
295
+ } else {
296
+ const bAct = bActs[name];
297
+ const mod: ActionModification = { namespace: ns, name };
298
+
299
+ const bPrin = bAct.appliesTo?.principalTypes ?? [];
300
+ const gPrin = gAct.appliesTo?.principalTypes ?? [];
301
+ const prinD = setDiff(bPrin, gPrin);
302
+ if (prinD.added.length > 0 || prinD.removed.length > 0) {
303
+ const breaking = prinD.removed.length > 0;
304
+ mod.principal_types = {
305
+ added: prinD.added,
306
+ removed: prinD.removed,
307
+ risk: breaking ? "breaking" : "review",
308
+ reason: breaking
309
+ ? "Principal types narrowed: existing policies for the removed type will fail validation."
310
+ : "Principal types widened: action applies to more principal types; policy effect may change.",
311
+ };
312
+ }
313
+
314
+ const bRes = bAct.appliesTo?.resourceTypes ?? [];
315
+ const gRes = gAct.appliesTo?.resourceTypes ?? [];
316
+ const resD = setDiff(bRes, gRes);
317
+ if (resD.added.length > 0 || resD.removed.length > 0) {
318
+ const breaking = resD.removed.length > 0;
319
+ mod.resource_types = {
320
+ added: resD.added,
321
+ removed: resD.removed,
322
+ risk: breaking ? "breaking" : "review",
323
+ reason: breaking
324
+ ? "Resource types narrowed: existing policies for the removed type will fail validation."
325
+ : "Resource types widened: action applies to more resource types; policy effect may change.",
326
+ };
327
+ }
328
+
329
+ const ctxChanges = diffAttributes(
330
+ bAct.appliesTo?.context?.attributes,
331
+ gAct.appliesTo?.context?.attributes,
332
+ "context attribute"
333
+ );
334
+ if (ctxChanges.length > 0) {
335
+ mod.context_changes = ctxChanges.map((c) => ({
336
+ attr: c.attr,
337
+ change: c.change as ContextChange["change"],
338
+ ...(c.old_type !== undefined ? { old_type: c.old_type } : {}),
339
+ ...(c.new_type !== undefined ? { new_type: c.new_type } : {}),
340
+ risk: c.risk,
341
+ reason: c.reason,
342
+ }));
343
+ }
344
+
345
+ if (mod.principal_types || mod.resource_types || mod.context_changes) {
346
+ diff.actions.modified.push(mod);
347
+ }
348
+ }
349
+ }
350
+
351
+ for (const name of Object.keys(bActs)) {
352
+ if (!(name in gActs)) {
353
+ diff.actions.removed.push({
354
+ namespace: ns,
355
+ name,
356
+ risk: "breaking",
357
+ reason: "Action removed: policies referencing it become invalid; runtime requests for it will fail.",
358
+ });
359
+ }
360
+ }
361
+ }
362
+ }
363
+
364
+ function diffCommonTypes(
365
+ blue: CanonicalSchema,
366
+ green: CanonicalSchema,
367
+ diff: SchemaDiff
368
+ ): void {
369
+ const allNamespaces = new Set([...Object.keys(blue), ...Object.keys(green)]);
370
+ for (const ns of allNamespaces) {
371
+ const bCt = blue[ns]?.commonTypes ?? {};
372
+ const gCt = green[ns]?.commonTypes ?? {};
373
+
374
+ for (const name of Object.keys(gCt)) {
375
+ if (!(name in bCt)) diff.common_types.added.push({ namespace: ns, name });
376
+ }
377
+ for (const name of Object.keys(bCt)) {
378
+ if (!(name in gCt)) {
379
+ diff.common_types.removed.push({
380
+ namespace: ns,
381
+ name,
382
+ risk: "review",
383
+ reason: "Common type removed: any entity or action referencing it will cause policies to fail validation.",
384
+ });
385
+ } else if (JSON.stringify(bCt[name]) !== JSON.stringify(gCt[name])) {
386
+ diff.common_types.modified.push({
387
+ namespace: ns,
388
+ name,
389
+ risk: "review",
390
+ reason: "Common type definition changed: review every entity/action that references it.",
391
+ });
392
+ }
393
+ }
394
+ }
395
+ }
396
+
397
+ function computeRiskLevel(diff: SchemaDiff): Risk {
398
+ const allRisks: Risk[] = [];
399
+ diff.entity_types.removed.forEach((e) => allRisks.push(e.risk));
400
+ diff.entity_types.modified.forEach((m) => {
401
+ if (m.member_of_changes) allRisks.push(m.member_of_changes.risk);
402
+ m.attribute_changes?.forEach((c) => allRisks.push(c.risk));
403
+ });
404
+ diff.actions.removed.forEach((a) => allRisks.push(a.risk));
405
+ diff.actions.modified.forEach((m) => {
406
+ if (m.principal_types) allRisks.push(m.principal_types.risk);
407
+ if (m.resource_types) allRisks.push(m.resource_types.risk);
408
+ m.context_changes?.forEach((c) => allRisks.push(c.risk));
409
+ });
410
+ diff.common_types.removed.forEach((c) => allRisks.push(c.risk));
411
+ diff.common_types.modified.forEach((c) => allRisks.push(c.risk));
412
+
413
+ if (allRisks.includes("breaking")) return "breaking";
414
+ if (allRisks.includes("review")) return "review";
415
+ return "safe";
416
+ }
417
+
418
+ function computeSummary(diff: SchemaDiff): string {
419
+ const parts: string[] = [];
420
+ const breakingCount =
421
+ diff.entity_types.removed.length +
422
+ diff.actions.removed.length +
423
+ diff.entity_types.modified.reduce((acc, m) => {
424
+ const memberBreaking = m.member_of_changes?.risk === "breaking" ? 1 : 0;
425
+ const attrsBreaking = (m.attribute_changes ?? []).filter((c) => c.risk === "breaking").length;
426
+ return acc + memberBreaking + attrsBreaking;
427
+ }, 0) +
428
+ diff.actions.modified.reduce((acc, m) => {
429
+ const pBreak = m.principal_types?.risk === "breaking" ? 1 : 0;
430
+ const rBreak = m.resource_types?.risk === "breaking" ? 1 : 0;
431
+ const cBreak = (m.context_changes ?? []).filter((c) => c.risk === "breaking").length;
432
+ return acc + pBreak + rBreak + cBreak;
433
+ }, 0);
434
+
435
+ if (diff.namespaces_added.length) parts.push(`${diff.namespaces_added.length} namespace(s) added`);
436
+ if (diff.namespaces_removed.length) parts.push(`${diff.namespaces_removed.length} namespace(s) removed`);
437
+ if (diff.entity_types.added.length) parts.push(`${diff.entity_types.added.length} entity type(s) added`);
438
+ if (diff.entity_types.removed.length) parts.push(`${diff.entity_types.removed.length} entity type(s) removed`);
439
+ if (diff.entity_types.modified.length) parts.push(`${diff.entity_types.modified.length} entity type(s) modified`);
440
+ if (diff.actions.added.length) parts.push(`${diff.actions.added.length} action(s) added`);
441
+ if (diff.actions.removed.length) parts.push(`${diff.actions.removed.length} action(s) removed`);
442
+ if (diff.actions.modified.length) parts.push(`${diff.actions.modified.length} action(s) modified`);
443
+ if (diff.common_types.added.length) parts.push(`${diff.common_types.added.length} common type(s) added`);
444
+ if (diff.common_types.removed.length) parts.push(`${diff.common_types.removed.length} common type(s) removed`);
445
+ if (diff.common_types.modified.length) parts.push(`${diff.common_types.modified.length} common type(s) modified`);
446
+
447
+ if (parts.length === 0) return "No schema changes detected.";
448
+
449
+ const breaking = breakingCount > 0 ? ` (${breakingCount} BREAKING)` : "";
450
+ return `Schema diff: ${parts.join(", ")}${breaking}.`;
451
+ }
452
+
453
+ export async function handleDiffSchema(input: DiffSchemaInput): Promise<SchemaDiff> {
454
+ let blueJson: CanonicalSchema;
455
+ let greenJson: CanonicalSchema;
456
+
457
+ try {
458
+ blueJson = normalizeToCanonical(input.blue);
459
+ } catch (e) {
460
+ return errorResult(`blue schema: ${e instanceof Error ? e.message : String(e)}`);
461
+ }
462
+ try {
463
+ greenJson = normalizeToCanonical(input.green);
464
+ } catch (e) {
465
+ return errorResult(`green schema: ${e instanceof Error ? e.message : String(e)}`);
466
+ }
467
+
468
+ const diff: SchemaDiff = {
469
+ namespaces_added: [],
470
+ namespaces_removed: [],
471
+ entity_types: { added: [], removed: [], modified: [] },
472
+ actions: { added: [], removed: [], modified: [] },
473
+ common_types: { added: [], removed: [], modified: [] },
474
+ summary: "",
475
+ risk_level: "safe",
476
+ };
477
+
478
+ const blueNs = new Set(Object.keys(blueJson));
479
+ const greenNs = new Set(Object.keys(greenJson));
480
+ for (const ns of greenNs) if (!blueNs.has(ns)) diff.namespaces_added.push(ns);
481
+ for (const ns of blueNs) if (!greenNs.has(ns)) diff.namespaces_removed.push(ns);
482
+
483
+ diffEntityTypes(
484
+ blueJson,
485
+ greenJson,
486
+ diff,
487
+ new Set(diff.namespaces_removed),
488
+ new Set(diff.namespaces_added)
489
+ );
490
+ diffActions(blueJson, greenJson, diff);
491
+ diffCommonTypes(blueJson, greenJson, diff);
492
+
493
+ diff.summary = computeSummary(diff);
494
+ diff.risk_level = computeRiskLevel(diff);
495
+
496
+ return diff;
497
+ }
498
+
499
+ function errorResult(error: string): SchemaDiff {
500
+ return {
501
+ namespaces_added: [],
502
+ namespaces_removed: [],
503
+ entity_types: { added: [], removed: [], modified: [] },
504
+ actions: { added: [], removed: [], modified: [] },
505
+ common_types: { added: [], removed: [], modified: [] },
506
+ summary: "",
507
+ risk_level: "safe",
508
+ error,
509
+ };
510
+ }