@homeflare/alchemy 0.32.0 → 0.34.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 (83) hide show
  1. package/dist/grafana/alerting-provenance.d.ts +80 -0
  2. package/dist/grafana/alerting-provenance.d.ts.map +1 -0
  3. package/dist/grafana/contact-point-fixtures.d.ts +28 -0
  4. package/dist/grafana/contact-point-fixtures.d.ts.map +1 -0
  5. package/dist/grafana/contact-point.d.ts +99 -0
  6. package/dist/grafana/contact-point.d.ts.map +1 -0
  7. package/dist/grafana/dashboard-model.d.ts +47 -0
  8. package/dist/grafana/dashboard-model.d.ts.map +1 -0
  9. package/dist/grafana/dashboard.d.ts +98 -0
  10. package/dist/grafana/dashboard.d.ts.map +1 -0
  11. package/dist/grafana/datasource.d.ts +2 -9
  12. package/dist/grafana/datasource.d.ts.map +1 -1
  13. package/dist/grafana/folder.d.ts +100 -0
  14. package/dist/grafana/folder.d.ts.map +1 -0
  15. package/dist/grafana/index.d.ts +16 -9
  16. package/dist/grafana/index.d.ts.map +1 -1
  17. package/dist/grafana/index.js +325 -16
  18. package/dist/grafana/index.js.map +15 -5
  19. package/dist/grafana/message-template.d.ts +70 -0
  20. package/dist/grafana/message-template.d.ts.map +1 -0
  21. package/dist/grafana/mute-timing.d.ts +112 -0
  22. package/dist/grafana/mute-timing.d.ts.map +1 -0
  23. package/dist/grafana/providers.d.ts +1 -1
  24. package/dist/grafana/providers.d.ts.map +1 -1
  25. package/dist/grafana/provisioned.d.ts +11 -0
  26. package/dist/grafana/provisioned.d.ts.map +1 -0
  27. package/dist/grafana/secret-refs.d.ts +19 -0
  28. package/dist/grafana/secret-refs.d.ts.map +1 -0
  29. package/dist/grafana/subset-match.d.ts +2 -0
  30. package/dist/grafana/subset-match.d.ts.map +1 -0
  31. package/dist/linux/index.d.ts +1 -1
  32. package/dist/linux/index.d.ts.map +1 -1
  33. package/dist/linux/index.js +3 -1
  34. package/dist/linux/index.js.map +1 -1
  35. package/dist/proxmox/index.js +76 -45
  36. package/dist/proxmox/index.js.map +6 -6
  37. package/dist/proxmox/zfs-pool-form.d.ts +124 -0
  38. package/dist/proxmox/zfs-pool-form.d.ts.map +1 -0
  39. package/dist/proxmox/zfs-pool-wire.d.ts +78 -0
  40. package/dist/proxmox/zfs-pool-wire.d.ts.map +1 -0
  41. package/dist/proxmox/zfs-pool.d.ts +27 -52
  42. package/dist/proxmox/zfs-pool.d.ts.map +1 -1
  43. package/docs/grafana-alerting.md +145 -0
  44. package/docs/grafana-folder-dashboard.md +145 -0
  45. package/docs/grafana.md +12 -10
  46. package/docs/upstream-conformance.md +31 -4
  47. package/package.json +1 -1
  48. package/src/grafana/alerting-provenance.ts +109 -0
  49. package/src/grafana/contact-point-fixtures.ts +25 -0
  50. package/src/grafana/contact-point-provenance.test.ts +113 -0
  51. package/src/grafana/contact-point-secrets.test.ts +117 -0
  52. package/src/grafana/contact-point.test.ts +123 -0
  53. package/src/grafana/contact-point.ts +179 -0
  54. package/src/grafana/dashboard-model.test.ts +111 -0
  55. package/src/grafana/dashboard-model.ts +62 -0
  56. package/src/grafana/dashboard.test.ts +232 -0
  57. package/src/grafana/dashboard.ts +161 -0
  58. package/src/grafana/datasource.ts +5 -27
  59. package/src/grafana/folder.test.ts +212 -0
  60. package/src/grafana/folder.ts +161 -0
  61. package/src/grafana/index.ts +42 -9
  62. package/src/grafana/message-template.test.ts +212 -0
  63. package/src/grafana/message-template.ts +114 -0
  64. package/src/grafana/mute-timing.test.ts +227 -0
  65. package/src/grafana/mute-timing.ts +160 -0
  66. package/src/grafana/providers.ts +13 -1
  67. package/src/grafana/provisioned.ts +49 -0
  68. package/src/grafana/secret-refs.ts +48 -0
  69. package/src/grafana/subset-match.ts +71 -0
  70. package/src/linux/index-export.test.ts +25 -0
  71. package/src/linux/index.ts +15 -1
  72. package/src/proxmox/constraints-host-forms.test.ts +1 -1
  73. package/src/proxmox/zfs-pool-adopt.test.ts +26 -0
  74. package/src/proxmox/zfs-pool-form.ts +236 -0
  75. package/src/proxmox/zfs-pool-read-failure.test.ts +118 -0
  76. package/src/proxmox/zfs-pool-wire.ts +132 -0
  77. package/src/proxmox/zfs-pool.ts +106 -76
  78. package/dist/proxmox/zfs-pool-spec.d.ts +0 -27
  79. package/dist/proxmox/zfs-pool-spec.d.ts.map +0 -1
  80. package/dist/proxmox/zfs-pool-write.d.ts +0 -128
  81. package/dist/proxmox/zfs-pool-write.d.ts.map +0 -1
  82. package/src/proxmox/zfs-pool-spec.ts +0 -76
  83. package/src/proxmox/zfs-pool-write.ts +0 -196
@@ -0,0 +1,145 @@
1
+ # `Grafana.Folder` and `Grafana.Dashboard` — `@homeflare/alchemy/grafana`
2
+
3
+ Built 2026-09-24 on the same `@distilled.cloud/grafana` (aliased onto
4
+ `@homeflare/distilled-grafana@0.2.0`) operations that fixed the SDK gap
5
+ [grafana.md](./grafana.md#the-sdk-gap--history-and-what-shipped-on-top-of-the-fix) recorded:
6
+ `createFolder`/`getFolderByUID`/`updateFolder`/`deleteFolder`/`getFolders` and
7
+ `postDashboard`/`getDashboard`/`deleteDashboard`, plus a typed `PreconditionFailed` (412) for
8
+ dashboard version conflicts. No live call was made writing this PR — see "Measured vs inferred"
9
+ below for exactly which claims that limits.
10
+
11
+ ## Both follow `Grafana.Datasource`'s doctrine
12
+
13
+ Same file shape (`resource.ts`'s shared `GrafanaSpec`/`grafanaOperations`/`grafanaHandlers`), same
14
+ credentials layer (`grafanaProviders(target)`), same **uid required, never generated** reasoning —
15
+ `getFolderByUID`/`updateFolder`/`deleteFolder` and `getDashboard`/`postDashboard`/`deleteDashboard`
16
+ are all UID-keyed, and neither `getFolders` nor a dashboard list endpoint takes a per-uid filter,
17
+ so locating an object this resource didn't create would mean listing everything and matching by
18
+ name — the ambiguity requiring `uid` up front sidesteps.
19
+
20
+ ## `Grafana.Folder`
21
+
22
+ ```ts
23
+ import { GrafanaFolder, grafanaProviders } from '@homeflare/alchemy/grafana';
24
+
25
+ export class Infra extends GrafanaFolder('infra-folder', {
26
+ uid: 'infra',
27
+ title: 'Infrastructure',
28
+ }) {}
29
+ ```
30
+
31
+ - **`description` is write-only** — MEASURED against the SDK's generated types: `Folder` (the GET
32
+ response) has no `description` field at all, though `CreateFolderRequest`/`UpdateFolderRequest`
33
+ both accept one. Sent on every create/update that declares it; never compared, the same
34
+ treatment `Grafana.Datasource` gives `secureJsonDataRefs` for the same reason (nothing to diff
35
+ against).
36
+ - **Nesting (`parentUid`) is create-only** — MEASURED: `UpdateFolderRequest` has no `parentUid`
37
+ field (`folder_uid`, `description`, `overwrite`, `title`, `version` only). A declaration whose
38
+ `parentUid` no longer matches the live folder REFUSES with `GrafanaFolderReparentError` rather
39
+ than silently doing nothing or deleting-and-recreating under the new parent (which — next
40
+ bullet — takes every dashboard and alert rule in the folder with it). Move a folder by hand in
41
+ Grafana, then update the declaration.
42
+ - **Delete defaults to `retain`** — `deleteFolder`'s own operation description says it deletes
43
+ "all dashboards (and their alerts) stored in the folder... cannot be reverted... also deletes
44
+ all the subfolders." That is the multi-object cascade `openbao/mount.ts` guards with
45
+ `defaultRemovalPolicy: 'retain'`, verified there against Alchemy's own `Apply.ts` (not
46
+ re-verified here; same engine, same option — see `folder.ts`'s header). A stack that wants the
47
+ cascade opts in with `.pipe(RemovalPolicy.destroy())`; `destroy` still implements the real
48
+ `DELETE` in full either way (S11). `Grafana.Dashboard` does **not** default to retain — see its
49
+ own section below.
50
+
51
+ ## `Grafana.Dashboard`
52
+
53
+ ```ts
54
+ import { GrafanaDashboard, grafanaProviders } from '@homeflare/alchemy/grafana';
55
+
56
+ export class FleetOverview extends GrafanaDashboard('fleet-overview-dashboard', {
57
+ uid: 'fleet-overview',
58
+ folderUid: 'infra',
59
+ dashboard: {
60
+ title: 'Fleet Overview',
61
+ panels: [/* … exported straight from Grafana's own "Export as JSON" … */],
62
+ },
63
+ }) {}
64
+ ```
65
+
66
+ - **The declared content is the dashboard JSON model** — the same shape Grafana's own "Export as
67
+ JSON" produces. `dashboard-model.ts`'s `normalizeModel` strips `id`/`version`/`iteration`
68
+ (Grafana-managed, injected/incremented on every save) and forces `uid` to this resource's own
69
+ value regardless of where — or whether — the source model carried one, before ANY comparison.
70
+ That is what makes a pasted export (which always embeds the OLD live uid) and a freshly-read
71
+ live model compare equal, and what makes a no-change plan a true noop despite those fields
72
+ moving on every live save.
73
+ - **Array order is significant, and nothing here needs a `sortedSet`** — `panels` (screen layout
74
+ via its own `gridPos`, but the array order also drives Grafana's internal panel indexing and
75
+ `repeat` behavior) and each panel's `targets` (query execution/legend order) are compared
76
+ positionally, never reordered. Nothing in a dashboard model is a genuinely unordered SET the way
77
+ UniFi's `trustedDhcpServerIpAddresses` is (`unifi/network-form.ts`'s `sortedSet`) — this family
78
+ has no field that needs one. `schemaVersion` is deliberately left comparing as-is — NOT MEASURED
79
+ whether Grafana rewrites it on a plain save, so normalizing it away on a guess risked hiding a
80
+ genuine schema mismatch instead of a false diff; see `dashboard-model.ts` for the exact reasoning
81
+ and what evidence would justify adding it to the volatile-field list.
82
+ - **`matches` compares "is the declaration a subset of what is live", not plain equality** —
83
+ `dashboard-model.ts`'s `declaredContentMatches`, added after an adversarial review of this PR
84
+ found plain structural equality (`alchemy/Diff`'s `deepEqual`, used in an earlier version of this
85
+ PR) made an ordinary, never-edited dashboard show `update` FOREVER: Grafana's own schema
86
+ migration on save decorates any panel or target whose `datasource` is absent with an explicit
87
+ `{type, uid}` reference — documented Grafana behavior, not re-measured against a live instance in
88
+ this PR. A declared panel that never sets `datasource` therefore read back with that field
89
+ populated, disagreed with the declaration, got written back undecorated, and got redecorated
90
+ again — non-convergent. `declaredContentMatches` instead asks only whether every field the
91
+ DECLARATION mentions, at any depth, still matches what is live; `live` may carry additional
92
+ fields Grafana injected that the declaration never mentioned. Real content drift (a field the
93
+ declaration DOES mention changing, or vanishing) is still caught exactly as before. The
94
+ trade-off, deliberate: **removing** a field from the declaration can no longer force Grafana to
95
+ drop a value it — or a previous declaration — set; only an explicit replacement value changes
96
+ one. This is the same "undeclared means don't care, never means force-to-default" convention this
97
+ whole kit already uses for a plain optional prop, now applied inside the JSON model too.
98
+ - **412 version conflicts fail loudly, never overwritten blindly.** `update` injects the version
99
+ this resource just read (from the same `fetchLive` call `reconcile` already made) into the model
100
+ it sends, and `overwrite` is never set. If Grafana's own check still fails — someone saved in the
101
+ gap between that read and this write — `PreconditionFailed` is left uncaught: the whole reconcile
102
+ fails and nothing is written. The next `plan`/`deploy` re-reads the new live version fresh; there
103
+ is no separate retry loop in this file, because that next run already is the re-read.
104
+ - **No retain default**, unlike `Grafana.Folder`. Deleting one dashboard is a single-object blast
105
+ radius — the same class `Grafana.Datasource` already accepts without a retain guard, not
106
+ `Grafana.Folder`'s multi-object cascade.
107
+
108
+ ## Provisioned objects — refused for writes, adoption is fine
109
+
110
+ A folder or dashboard that a provisioning FILE manages (rather than this API) carries a live
111
+ signal — `Folder.managedBy` (non-empty) and `DashboardMeta.provisioned`/`provisionedExternalId` —
112
+ that `update`/`destroy` check before ever calling the SDK; `GrafanaProvisionedObjectError` names
113
+ the owning file when Grafana reports one (dashboards only — a folder's `managedBy` value is
114
+ reported as-is, since the SDK does not separately expose a folder's owning file). `create` is
115
+ never at risk: it only runs when `fetchLive` already found nothing.
116
+
117
+ `matches` still compares structurally regardless of provisioned status, so a mismatched
118
+ declaration against a provisioned object shows `update` in a plan — never a silent, permanently
119
+ hidden noop — and only fails, loudly, when reconcile actually tries to write. A declaration that
120
+ already matches what is live never reaches `update`/`destroy` at all (the shared engine's
121
+ observe-before-write path), so **adopting a provisioned object read-only is safe.**
122
+
123
+ ⚠️ **Measured vs inferred (no live call in this PR).** `DashboardMeta.provisioned`/
124
+ `provisionedExternalId` are exactly the fields Grafana's classic dashboard-read response types for
125
+ this purpose — high confidence, standard shape. `Folder.managedBy` is INFERRED from the SDK's
126
+ schema only: no live instance in this house currently has a file-provisioned FOLDER to read against
127
+ (`teslamate-grafana` has none at all — see grafana.md). Treating any non-empty `managedBy` as
128
+ provisioned, rather than matching specific string values that were never observed live, is the
129
+ conservative reading; confirm against a real provisioned folder before relying on the exact value in
130
+ an error message. The panel/target `datasource` auto-decoration `declaredContentMatches` (above)
131
+ exists to tolerate is documented Grafana schema-migration behavior, also not re-measured live in
132
+ this PR. And `updateFolder`'s `version` field — sent optimistically for concurrency — carries the
133
+ SDK's own caveat "only used by the legacy folder implementation"; whether Grafana's newer
134
+ unified-storage folder backend honors it at all is unverified (`folder.ts`'s `update`).
135
+
136
+ ## Tests
137
+
138
+ `folder.test.ts`, `dashboard.test.ts` and `dashboard-model.test.ts` mirror `datasource.test.ts`'s
139
+ fake-Grafana harness (`fake-grafana.ts` — the real distilled protocol, only the wire responses
140
+ faked). Each proves: a GET never carries a body (`fake.bodies[0]` is `undefined` — the exact
141
+ "distilled JSON-encodes unknown keys as a body" trap this family's shared memory names); a
142
+ provisioned object refuses update and destroy with no write sent; a transient failure (403/401)
143
+ propagates rather than folding to absent; an unchanged declaration is a noop despite `id`/
144
+ `version`/`iteration` differing; a 412 propagates uncaught; and the folder `parentUid` reparent
145
+ refusal sends no write.
package/docs/grafana.md CHANGED
@@ -6,15 +6,14 @@ any status check. Built 2026-09-24 against the live target `teslamate-grafana.se
6
6
  measured version **13.1.3** (`teslamate/grafana:4.2.0`), listening on `127.0.0.1:3100`, served
7
7
  publicly at `tesla.homeflare.dev/dash/`.
8
8
 
9
- ## The SDK gap — why only `Datasource` ships
9
+ ## The SDK gap — history, and what shipped on top of the fix
10
10
 
11
11
  ✅ **SDK-level gap fixed 2026-09-24** (`@distilled.cloud/grafana`, aliased onto
12
- `@homeflare/distilled-grafana` — 0.1.0 pre-release, 0.2.0 once its `minor` changeset publishes it;
13
- see [docs/distilled-interim.md](./distilled-interim.md)).
14
- The gap below is otherwise unchanged and left as evidence of what was measured and why; only its
15
- status line moved. **Still true today:** only `Grafana.Datasource` ships as a house _resource_ —
16
- `Grafana.Folder`, `Grafana.Dashboard`, `Grafana.AlertRule` and friends are follow-up work on top of
17
- the now-available SDK operations, not part of this fix.
12
+ `@homeflare/distilled-grafana` — 0.2.0; see [docs/distilled-interim.md](./distilled-interim.md)).
13
+ The gap below is left as evidence of what was measured and why. `Grafana.Folder` and
14
+ `Grafana.Dashboard` now ship on top of the unblocked operations —
15
+ [grafana-folder-dashboard.md](./grafana-folder-dashboard.md) covers both in full. `Grafana.AlertRule`,
16
+ `ContactPoint`, `NotificationPolicy` and `MuteTiming` remain follow-up work, not part of this PR.
18
17
 
19
18
  Measured 2026-09-24 against the published `@distilled.cloud/grafana@1.0.0-rc.12` tarball (its
20
19
  `lib/services/grafana.d.ts`, 4,938 lines): the package had **no create/read/update/delete
@@ -125,8 +124,11 @@ export class TeslaMateDatasource extends GrafanaDatasource('teslamate-datasource
125
124
 
126
125
  ## Not covered
127
126
 
128
- - **Folders, dashboards, alert rules, contact points, notification policies, mute timings.** The
129
- SDK-level blocker is fixed (above); no house `Resource` calls these operations yet — follow-up
130
- work, not blocked on anything.
127
+ - **`Grafana.Folder` and `Grafana.Dashboard` ship** — see
128
+ [grafana-folder-dashboard.md](./grafana-folder-dashboard.md), not repeated here.
129
+ - **`Grafana.ContactPoint`, `Grafana.MuteTiming` and `Grafana.MessageTemplate` ship** — see
130
+ [grafana-alerting.md](./grafana-alerting.md), not repeated here.
131
+ - **`Grafana.AlertRuleGroup` and `Grafana.NotificationPolicy`** — stacked follow-up PRs on the same
132
+ operations, per grafana-alerting.md's own ordering; not blocked on anything.
131
133
  - **`read` never answers `Unowned`** — the same open gap forgejo's and netbox's own families
132
134
  still carry (see upstream-conformance.md); a maintainer decision on the ownership-check shape.
@@ -259,10 +259,37 @@ HttpClient` client. The old status-carrying `NetboxError` and its `cause.status
259
259
  `@homeflare/distilled-grafana` (0.1.0 pre-release, 0.2.0 once this PR's `minor` changeset
260
260
  releases it), aliased onto `@distilled.cloud/grafana` the same S22 route
261
261
  `@homeflare/distilled-netbox` established. `Grafana.Datasource`'s props/attributes/generated code
262
- are unchanged (diffed operation-by-operation against the prior commit). **Still open, and not
263
- this PR's scope:** no `Grafana.Folder`/`Dashboard`/`AlertRule`/`ContactPoint`/
264
- `NotificationPolicy`/`MuteTiming` resource calls the newly-available operations yet — follow-up
265
- PRs, no longer blocked on the SDK.
262
+ are unchanged (diffed operation-by-operation against the prior commit).
263
+
264
+ ✅ **`Grafana.Folder`/`Grafana.Dashboard` shipped** (PR 245, on top of the same
265
+ `@distilled.cloud/grafana` operations S22 unblocked) — the uid-required doctrine, the
266
+ provisioned-object refusal, the parentUid-is-create-only refusal and the volatile-field
267
+ normalization that makes an unchanged dashboard a true noop are all in
268
+ [grafana-folder-dashboard.md](./grafana-folder-dashboard.md), not repeated here.
269
+
270
+ ✅ **`Grafana.ContactPoint`/`Grafana.MuteTiming`/`Grafana.MessageTemplate` shipped** (this PR,
271
+ first of three stacked PRs — `Grafana.AlertRuleGroup` then `Grafana.NotificationPolicy` follow):
272
+ the foreign-provenance refusal (`alerting-provenance.ts`, an allowlist of Grafana's own
273
+ `ProvenanceNone`/`ProvenanceAPI` values, measured against `pkg/services/ngalert/models` and
274
+ Grafana's v13.1 docs) and the secret-settings-ref seam (`secret-refs.ts`, shared with
275
+ `Grafana.Datasource`) are both new shared building blocks — full detail:
276
+ [grafana-alerting.md](./grafana-alerting.md).
277
+ - 🔴 **New SDK gap found, not fixed here:** `MuteTimeInterval`'s generated type has no
278
+ `provenance`/`version` fields, and the SDK's own `TimeInterval` type is missing the real
279
+ Alertmanager time-interval fields (`weekdays`/`times`/`months`/etc.) entirely — Grafana's real
280
+ API returns and accepts all of them. NOT a data-loss gap: `@distilled.cloud/core`'s response
281
+ decoding never runs a strict schema decode (`JSON.parse` + a key-rename pass that leaves any
282
+ unmodeled key verbatim — `protocol-http.ts`'s `mapKeys`, confirmed by driving the real
283
+ operation through this family's fake-Grafana harness), so `mute-timing.ts` reads/writes both
284
+ through a locally-widened type instead. Same S22 fix route as the other named gaps
285
+ (regenerate the distilled clone's spec, copy forward) — not attempted here, since
286
+ `packages/distilled-grafana/src/` is vendored and never hand-edited.
287
+ - Measured, read-only, same census as PR 245's: `teslamate-grafana` has no contact points, mute
288
+ timings or templates configured either (consistent with the alerting-provisioning gap having
289
+ been real, not merely unexploited).
290
+
291
+ **Still open, not this PR's scope:** `Grafana.AlertRuleGroup`/`Grafana.NotificationPolicy` —
292
+ the next two stacked PRs, no longer blocked on the SDK.
266
293
  - ⛔ **Same open gaps as `forgejo/*` and `netbox/*` above:** `read` never answers `Unowned`
267
294
  (H1), and credentials come from an explicit env var NAME at call time rather than an
268
295
  `alchemy/Auth` provider (S24) — here the house's own `grafanaCredentials(target)`, not the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.32.0",
3
+ "version": "0.34.0",
4
4
  "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
5
5
  "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
6
6
  "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
@@ -0,0 +1,109 @@
1
+ /**
2
+ * The shared refusal for a foreign-provenance ALERTING-PROVISIONING object — `Grafana.ContactPoint`,
3
+ * `Grafana.MessageTemplate`, `Grafana.AlertRuleGroup` and `Grafana.NotificationPolicy` each read a
4
+ * `provenance` string off what Grafana returns (`EmbeddedContactPoint.provenance`,
5
+ * `NotificationTemplate.provenance`, `ProvisionedAlertRule.provenance` per rule, `Route.provenance`
6
+ * on the policy tree — all measured against `@distilled.cloud/grafana`'s generated types,
7
+ * `packages/distilled-grafana/src/services/grafana.ts`) and refuse a write when it disagrees with
8
+ * what this family is allowed to own. Sibling to `provisioned.ts`, not a rename of it — the two
9
+ * refusals guard different fields with different value shapes (`managedBy`/`provisioned` there,
10
+ * `provenance` here) for two different Grafana API surfaces (classic folder/dashboard vs. alerting
11
+ * provisioning), but both end in the SAME tagged error, reused from `provisioned.ts` rather than
12
+ * declared twice: "a write was refused because something else owns this object" is one condition
13
+ * across the whole family, the spirit PR 245's provisioned refusal already established.
14
+ *
15
+ * ★ THE ALLOWLIST, MEASURED AGAINST GRAFANA'S OWN SOURCE (not this SDK, which only echoes the
16
+ * value back on read — never re-fetched live in this PR): Grafana's `pkg/services/ngalert/
17
+ * models` package defines a `Provenance` type with `ProvenanceNone = ""` (nothing has claimed
18
+ * the object — fresh, or written through the classic non-provisioning path), `ProvenanceAPI =
19
+ * "api"` (created or last written through THIS provisioning API — what this family's own
20
+ * create/update leaves behind), `ProvenanceFile = "file"` (Grafana's file-based provisioning),
21
+ * and `ProvenanceConvertedPrometheus = "converted_prometheus"` (the Prometheus-rules importer).
22
+ * Corroborated by Grafana's own docs (grafana.com/docs/grafana/v13.1/alerting/set-up/
23
+ * provision-alerting-resources/http-api-provisioning/), whose example payloads show
24
+ * `"provenance": "api"` on an alert rule this API created and `"provenance": "file"` on a
25
+ * file-provisioned mute timing and template.
26
+ *
27
+ * `WRITABLE_PROVENANCES` is therefore an ALLOWLIST of `{"", "api"}`, not a denylist of the two
28
+ * named foreign values — a provenance source not yet named above (a plugin importer, a future
29
+ * Grafana release) refuses by default instead of silently being treated as writable, the safer
30
+ * failure direction for a control-plane write.
31
+ *
32
+ * ⛔ A MISSING `provenance` FIELD IS TREATED AS FOREIGN, NEVER AS `ProvenanceNone` — fixed after an
33
+ * adversarial review of this PR found the first version failed OPEN: every resource mapped
34
+ * `live.provenance ?? ''`, and `''` (`ProvenanceNone`, writable) is indistinguishable from a
35
+ * field the response simply never included once both collapse through `??`. Every `provenance`
36
+ * field in this family is optional on the wire (`EmbeddedContactPoint.provenance?: string`, etc.
37
+ * — genuinely absent is a real, typed possibility, not a hypothetical), and `Grafana.MuteTiming`'s
38
+ * depends on an unmodeled-key passthrough this family has never observed against a real Grafana
39
+ * response (see its own file header) — a version, edge case or bug in that decode path that drops
40
+ * the field would, under the old `?? ''` rule, silently make a file-provisioned object look
41
+ * writable. `isForeignProvenance`/`refuseIfForeignProvenance` below keep `undefined` as its own
42
+ * value all the way through: ONLY a field Grafana explicitly reported as `""` or `"api"` is
43
+ * writable; missing entirely refuses, with a message that says so rather than naming a fabricated
44
+ * provenance value.
45
+ *
46
+ * ⚠️ CLIENT-SIDE DEFENSE, NOT THE ONLY GUARD — AND NOT RE-MEASURED LIVE IN THIS PR. Grafana's own
47
+ * alerting provisioning API server ALSO enforces this (unlike the classic folder/dashboard write
48
+ * API `provisioned.ts` guards, which has no such server-side check at all): grafana/grafana#103597
49
+ * ("Unable to delete file provisioned alert rules") reports exactly this block firing for a
50
+ * `file`-provenance object, and the `X-Disable-Provenance` header's own documented purpose is "by
51
+ * default, you cannot edit API-provisioned alerting resources in Grafana" (same doc page). This
52
+ * function exists anyway, for the same reason `provisioned.ts` does: a clear, typed, house error
53
+ * instead of whatever 400-with-a-string-message Grafana's server happens to return this version —
54
+ * and as a defense against the known server-side bug that same issue documents
55
+ * (`X-Disable-Provenance` failing to actually unlock a `file`-provenance object), which could
56
+ * otherwise leave a write silently rejected with only a generic `BadRequest` and no explanation
57
+ * of why.
58
+ *
59
+ * ⚠️ `Grafana.MuteTiming` ALSO USES THIS FUNCTION, BUT THROUGH A WIDENED LIVE TYPE — the SDK's
60
+ * generated `MuteTimeInterval` interface (the TS type for both `GET .../mute-timings` and
61
+ * `GET .../mute-timings/{name}`) declares no `provenance` field at all, unlike every other type
62
+ * named above. A first pass at this family assumed that meant the DATA was gone; it is not —
63
+ * MEASURED (see mute-timing.ts's own file header for the full account): `@distilled.cloud/core`'s
64
+ * response decoding never runs a strict schema decode, only `JSON.parse` + a key-rename pass that
65
+ * passes any key a type doesn't model through verbatim, so `provenance` survives on the actual
66
+ * runtime value even though the TS type doesn't know about it. `mute-timing.ts` reads it through
67
+ * a small local `& { provenance?: string }` intersection type rather than the SDK's own.
68
+ */
69
+ import * as Effect from 'effect/Effect';
70
+ import { GrafanaProvisionedObjectError } from './provisioned.ts';
71
+
72
+ export { GrafanaProvisionedObjectError };
73
+
74
+ const WRITABLE_PROVENANCES = new Set(['', 'api']);
75
+
76
+ /**
77
+ * True when `provenance` names an owner other than "this API" — INCLUDING when Grafana did not
78
+ * report the field at all (`undefined`). `undefined` is deliberately NEVER folded into `''`
79
+ * (`ProvenanceNone`) here — see the file header's "fails open" note. Only a value Grafana
80
+ * explicitly reported as `""` or `"api"` is writable.
81
+ */
82
+ export const isForeignProvenance = (provenance: string | undefined): boolean =>
83
+ provenance === undefined || !WRITABLE_PROVENANCES.has(provenance);
84
+
85
+ export const refuseIfForeignProvenance = (
86
+ kind:
87
+ | 'Grafana.ContactPoint'
88
+ | 'Grafana.MessageTemplate'
89
+ | 'Grafana.MuteTiming'
90
+ | 'Grafana.AlertRuleGroup'
91
+ | 'Grafana.NotificationPolicy',
92
+ id: string,
93
+ provenance: string | undefined,
94
+ ): Effect.Effect<never, GrafanaProvisionedObjectError> => {
95
+ const reason =
96
+ provenance === undefined
97
+ ? `did not report a provenance at all — this family cannot tell whether it is safe to own`
98
+ : `has provenance '${provenance}', not '(none)' or 'api'`;
99
+ return Effect.fail(
100
+ new GrafanaProvisionedObjectError({
101
+ message:
102
+ `${kind} '${id}' ${reason} — something other than this API may own it (file ` +
103
+ `provisioning, the Prometheus-rules importer, or similar), and writing here could take ` +
104
+ `over or corrupt an object this family does not actually control. Edit it at its actual ` +
105
+ `source instead — declaring this object read-only (a declaration matching what is live) ` +
106
+ `is safe and will never reach this refusal.`,
107
+ }),
108
+ );
109
+ };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Shared fixtures for `contact-point.test.ts`/`contact-point-secrets.test.ts`/
3
+ * `contact-point-provenance.test.ts` — split across three files to keep each under the house's
4
+ * 250-line cap. Deliberately NOT named `*.test.ts`: importing one test file from another would
5
+ * make `bun test`'s glob discovery execute its `describe` blocks twice (once directly, once as an
6
+ * import side effect of the importing file) — this file has none to run, only plain data.
7
+ */
8
+ export const LIST_PATH = '/api/v1/provisioning/contact-points';
9
+ export const ITEM_PATH = '/api/v1/provisioning/contact-points/slack-oncall';
10
+
11
+ export const liveJson = {
12
+ disableResolveMessage: false,
13
+ name: 'On-call Slack',
14
+ provenance: 'api',
15
+ settings: { recipient: '#oncall' },
16
+ type: 'slack',
17
+ uid: 'slack-oncall',
18
+ };
19
+
20
+ export const props = {
21
+ name: 'On-call Slack',
22
+ settings: { recipient: '#oncall' },
23
+ type: 'slack',
24
+ uid: 'slack-oncall',
25
+ };
@@ -0,0 +1,113 @@
1
+ /**
2
+ * `Grafana.ContactPoint`'s provenance refusal — both the explicit-foreign case (`provenance:
3
+ * 'file'`) and the missing-field-fails-closed case an adversarial review of this PR found. Split
4
+ * out of `contact-point.test.ts` to keep each file under the house's 250-line cap.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import * as Effect from 'effect/Effect';
8
+ import { GrafanaProvisionedObjectError } from './alerting-provenance.ts';
9
+ import { ITEM_PATH, LIST_PATH, liveJson, props } from './contact-point-fixtures.ts';
10
+ import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
11
+ import { grafanaOperations } from './resource.ts';
12
+ import { spec } from './contact-point.ts';
13
+
14
+ describe('update and destroy — foreign provenance refuses', () => {
15
+ test('a file-provisioned contact point refuses update and sends no PUT', async () => {
16
+ const fileProvisioned = { ...liveJson, provenance: 'file' };
17
+ const fake = fakeGrafana((method, url) =>
18
+ method === 'GET' && url.pathname === LIST_PATH
19
+ ? Response.json([fileProvisioned])
20
+ : fakeFailure(500, 'no write should be attempted'),
21
+ );
22
+ const failure = await Effect.runPromise(
23
+ Effect.flip(
24
+ grafanaOperations(spec)
25
+ .reconcile({ ...props, name: 'Renamed' })
26
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
27
+ ),
28
+ );
29
+ expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
30
+ expect(failure.message).toContain('file');
31
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
32
+ });
33
+
34
+ test('a file-provisioned contact point refuses destroy and sends no DELETE', async () => {
35
+ const fileProvisioned = { ...liveJson, provenance: 'file' };
36
+ const fake = fakeGrafana((method, url) =>
37
+ method === 'GET' && url.pathname === LIST_PATH
38
+ ? Response.json([fileProvisioned])
39
+ : fakeFailure(500, 'no DELETE should be attempted'),
40
+ );
41
+ const failure = await Effect.runPromise(
42
+ Effect.flip(
43
+ grafanaOperations(spec)
44
+ .destroy(props)
45
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
46
+ ),
47
+ );
48
+ expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
49
+ expect(fake.seen).toEqual([{ method: 'GET', path: LIST_PATH }]);
50
+ });
51
+ });
52
+
53
+ describe('missing provenance fails closed', () => {
54
+ // ⛔ THE REGRESSION AN ADVERSARIAL REVIEW OF THIS PR FOUND. An earlier version mapped
55
+ // `live.provenance ?? ''`, so a response that simply omitted the field looked identical to an
56
+ // explicit `ProvenanceNone` and was treated as writable — the guard failed OPEN. These three
57
+ // tests pin the fix: missing refuses (both writes), an EXPLICIT `""` is the only thing that
58
+ // allows one.
59
+ const noProvenanceField: Record<string, unknown> = { ...liveJson };
60
+ delete noProvenanceField.provenance;
61
+
62
+ test('a response with no provenance field at all refuses update', async () => {
63
+ const fake = fakeGrafana((method, url) =>
64
+ method === 'GET' && url.pathname === LIST_PATH
65
+ ? Response.json([noProvenanceField])
66
+ : fakeFailure(500, 'no write should be attempted'),
67
+ );
68
+ const failure = await Effect.runPromise(
69
+ Effect.flip(
70
+ grafanaOperations(spec)
71
+ .reconcile({ ...props, name: 'Renamed' })
72
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
73
+ ),
74
+ );
75
+ expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
76
+ expect(failure.message).toContain('did not report a provenance');
77
+ expect(fake.seen.every((s) => s.method === 'GET')).toBe(true);
78
+ });
79
+
80
+ test('a response with no provenance field at all refuses destroy', async () => {
81
+ const fake = fakeGrafana((method, url) =>
82
+ method === 'GET' && url.pathname === LIST_PATH
83
+ ? Response.json([noProvenanceField])
84
+ : fakeFailure(500, 'no DELETE should be attempted'),
85
+ );
86
+ const failure = await Effect.runPromise(
87
+ Effect.flip(
88
+ grafanaOperations(spec)
89
+ .destroy(props)
90
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
91
+ ),
92
+ );
93
+ expect(failure).toBeInstanceOf(GrafanaProvisionedObjectError);
94
+ expect(fake.seen).toEqual([{ method: 'GET', path: LIST_PATH }]);
95
+ });
96
+
97
+ test('an EXPLICIT empty-string provenance (ProvenanceNone) is writable', async () => {
98
+ const explicitlyNone = { ...liveJson, provenance: '' };
99
+ const fake = fakeGrafana((method, url) => {
100
+ if (method === 'GET' && url.pathname === LIST_PATH) return Response.json([explicitlyNone]);
101
+ if (method === 'PUT' && url.pathname === ITEM_PATH) {
102
+ return Response.json({ ...explicitlyNone, name: 'Renamed' });
103
+ }
104
+ return fakeFailure(500, 'unexpected request');
105
+ });
106
+ await Effect.runPromise(
107
+ grafanaOperations(spec)
108
+ .reconcile({ ...props, name: 'Renamed' })
109
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
110
+ );
111
+ expect(fake.seen.some((s) => s.method === 'PUT' && s.path === ITEM_PATH)).toBe(true);
112
+ });
113
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * `Grafana.ContactPoint`'s `secureSettingsRefs` seam (S25) — split out of `contact-point.test.ts`
3
+ * to keep each file under the house's 250-line cap. A secret value must reach the wire on write and
4
+ * never appear anywhere this resource returns or would persist.
5
+ */
6
+ import { describe, expect, test } from 'bun:test';
7
+ import * as Effect from 'effect/Effect';
8
+ import { LIST_PATH, liveJson, props } from './contact-point-fixtures.ts';
9
+ import { fakeFailure, fakeGrafana, fakeGrafanaLayer } from './fake-grafana.ts';
10
+ import { grafanaOperations } from './resource.ts';
11
+ import { GrafanaSecretRefUnsetError, spec } from './contact-point.ts';
12
+
13
+ const ENV_VAR = 'GRAFANA_FAMILY_TEST_CP_TOKEN';
14
+
15
+ describe('secure settings references (S25)', () => {
16
+ test('create resolves the env var fresh and merges it into settings, never as a prop', async () => {
17
+ process.env[ENV_VAR] = 'xoxb-s3cret';
18
+ try {
19
+ const fake = fakeGrafana((method, url) =>
20
+ method === 'POST' && url.pathname === LIST_PATH
21
+ ? Response.json(liveJson)
22
+ : fakeFailure(404, 'x'),
23
+ );
24
+ await Effect.runPromise(
25
+ spec
26
+ .create({ ...props, secureSettingsRefs: { url: ENV_VAR } })
27
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
28
+ );
29
+ expect(fake.bodies[0]).toMatchObject({
30
+ settings: { recipient: '#oncall', url: 'xoxb-s3cret' },
31
+ });
32
+ } finally {
33
+ delete process.env[ENV_VAR];
34
+ }
35
+ });
36
+
37
+ test('the secret reaches the wire on create but never the RETURNED attributes — proven end to end', async () => {
38
+ // ⛔ THE POINT OF THIS FAMILY'S TESTS, TIGHTENED AFTER AN ADVERSARIAL REVIEW OF THIS PR: the
39
+ // earlier version only asserted `JSON.stringify(props)` never contained the secret — trivially
40
+ // true, since `props` never held it to begin with (it lives in `secureSettingsRefs`, an env
41
+ // var NAME). This drives a REAL `reconcile` (create, then the read-back `resource.ts` always
42
+ // does) through the fake and stringifies what it actually RETURNS — the shape that would be
43
+ // persisted to state — proving the secret never survives there, not just that it was never in
44
+ // an input we already knew was secret-free.
45
+ process.env[ENV_VAR] = 'xoxb-s3cret';
46
+ try {
47
+ let created = false;
48
+ const fake = fakeGrafana((method, url) => {
49
+ if (method === 'GET' && url.pathname === LIST_PATH) {
50
+ return created
51
+ ? Response.json([
52
+ { ...liveJson, settings: { recipient: '#oncall', url: 'RedactedValue' } },
53
+ ])
54
+ : Response.json([]);
55
+ }
56
+ if (method === 'POST' && url.pathname === LIST_PATH) {
57
+ created = true;
58
+ return Response.json(liveJson);
59
+ }
60
+ return fakeFailure(500, 'unexpected request');
61
+ });
62
+ const declared = { ...props, secureSettingsRefs: { url: ENV_VAR } };
63
+ const attrs = await Effect.runPromise(
64
+ grafanaOperations(spec)
65
+ .reconcile(declared)
66
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
67
+ );
68
+ const createBody = fake.bodies[fake.seen.findIndex((s) => s.method === 'POST')];
69
+ // The wire genuinely carried it — the write would be useless otherwise.
70
+ expect(createBody).toMatchObject({ settings: { recipient: '#oncall', url: 'xoxb-s3cret' } });
71
+ // Nothing this resource returns or would persist does — the declaration, and the attributes
72
+ // `reconcile` hands back after reading the object right back through Grafana's own redaction.
73
+ expect(JSON.stringify(declared)).not.toContain('xoxb-s3cret');
74
+ expect(JSON.stringify(attrs)).not.toContain('xoxb-s3cret');
75
+ expect(attrs.settings).toEqual({ recipient: '#oncall', url: 'RedactedValue' });
76
+ } finally {
77
+ delete process.env[ENV_VAR];
78
+ }
79
+ });
80
+
81
+ test('create refuses with a typed error when the ref is unset — and sends nothing', async () => {
82
+ const fake = fakeGrafana(() => fakeFailure(500, 'should not be reached'));
83
+ const failure = await Effect.runPromise(
84
+ Effect.flip(
85
+ spec
86
+ .create({ ...props, secureSettingsRefs: { url: 'DEFINITELY_UNSET_VAR_XYZ' } })
87
+ .pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
88
+ ),
89
+ );
90
+ expect(failure).toBeInstanceOf(GrafanaSecretRefUnsetError);
91
+ expect(fake.seen).toEqual([]);
92
+ });
93
+
94
+ test('a redacted secure field on read never fails matches, even if props still names a stale value', async () => {
95
+ // ⛔ THE POINT: `settings.url` is declared here (a stale placeholder — the real value comes
96
+ // from `secureSettingsRefs` instead) specifically so a plain subset-match tolerance (live
97
+ // simply carrying an UNDECLARED extra key) is not what makes this pass — `nonSecretSettings`
98
+ // must actively exclude a key `secureSettingsRefs` names, even when `settings` also mentions
99
+ // it, or a redacted live value would show a permanent false `update`.
100
+ const declared = {
101
+ ...props,
102
+ secureSettingsRefs: { url: 'IRRELEVANT' },
103
+ settings: { recipient: '#oncall', url: 'https://placeholder.invalid' },
104
+ };
105
+ const redactedLive = { ...liveJson, settings: { recipient: '#oncall', url: 'RedactedValue' } };
106
+ const fake = fakeGrafana((method, url) =>
107
+ method === 'GET' && url.pathname === LIST_PATH
108
+ ? Response.json([redactedLive])
109
+ : fakeFailure(500, 'x'),
110
+ );
111
+ const live = await Effect.runPromise(
112
+ spec.fetchLive(declared).pipe(Effect.provide(fakeGrafanaLayer(fake.fetch))),
113
+ );
114
+ const attrs = live !== undefined ? spec.attributes(live, props) : undefined;
115
+ expect(attrs !== undefined && spec.matches(attrs, declared)).toBe(true);
116
+ });
117
+ });