@homeflare/alchemy 0.12.0 → 0.14.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.
- package/README.md +7 -0
- package/dist/github/index.d.ts +18 -0
- package/dist/github/index.d.ts.map +1 -0
- package/dist/github/index.js +161 -0
- package/dist/github/index.js.map +12 -0
- package/dist/github/repo-policy-form.d.ts +86 -0
- package/dist/github/repo-policy-form.d.ts.map +1 -0
- package/dist/github/repo-policy-guards.d.ts +72 -0
- package/dist/github/repo-policy-guards.d.ts.map +1 -0
- package/dist/github/repo-policy.d.ts +21 -0
- package/dist/github/repo-policy.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/proxmox/alertmanager-body.d.ts +54 -0
- package/dist/proxmox/alertmanager-body.d.ts.map +1 -0
- package/dist/proxmox/credential-literals.d.ts +36 -0
- package/dist/proxmox/credential-literals.d.ts.map +1 -0
- package/dist/proxmox/fake-pbs-notify.d.ts +23 -0
- package/dist/proxmox/fake-pbs-notify.d.ts.map +1 -0
- package/dist/proxmox/fake-pve.d.ts +5 -0
- package/dist/proxmox/fake-pve.d.ts.map +1 -1
- package/dist/proxmox/generated/pbs.d.ts +278 -0
- package/dist/proxmox/generated/pbs.d.ts.map +1 -1
- package/dist/proxmox/generated/pve.d.ts +57 -0
- package/dist/proxmox/generated/pve.d.ts.map +1 -1
- package/dist/proxmox/index.d.ts +8 -0
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +681 -183
- package/dist/proxmox/index.js.map +14 -4
- package/dist/proxmox/notification-matcher-form.d.ts +100 -0
- package/dist/proxmox/notification-matcher-form.d.ts.map +1 -0
- package/dist/proxmox/notification-matcher.d.ts +46 -0
- package/dist/proxmox/notification-matcher.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-matcher.d.ts +53 -0
- package/dist/proxmox/pbs-notification-matcher.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target-form.d.ts +63 -0
- package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target-lifecycle.d.ts +100 -0
- package/dist/proxmox/pbs-notification-target-lifecycle.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target-wire.d.ts +30 -0
- package/dist/proxmox/pbs-notification-target-wire.d.ts.map +1 -0
- package/dist/proxmox/pbs-notification-target.d.ts +123 -0
- package/dist/proxmox/pbs-notification-target.d.ts.map +1 -0
- package/dist/proxmox/resource.d.ts +4 -3
- package/dist/proxmox/resource.d.ts.map +1 -1
- package/dist/proxmox/write-only.d.ts +35 -0
- package/dist/proxmox/write-only.d.ts.map +1 -0
- package/dist/verify/fake-engine.d.ts +5 -0
- package/dist/verify/fake-engine.d.ts.map +1 -1
- package/docs/adopted-deploys.md +8 -0
- package/docs/pbs-alertmanager-body.md +73 -0
- package/docs/pbs-notifications.md +163 -0
- package/docs/proxmox.md +3 -0
- package/docs/repo-policy-ruleset-hazards.md +65 -0
- package/docs/repo-policy.md +155 -0
- package/package.json +5 -1
- package/src/github/index.ts +23 -0
- package/src/github/repo-policy-form.test.ts +239 -0
- package/src/github/repo-policy-form.ts +173 -0
- package/src/github/repo-policy-guards.ts +179 -0
- package/src/github/repo-policy.test.ts +58 -0
- package/src/github/repo-policy.ts +65 -0
- package/src/index.ts +1 -0
- package/src/proxmox/alertmanager-body.ts +125 -0
- package/src/proxmox/credential-literals.test.ts +148 -0
- package/src/proxmox/credential-literals.ts +74 -0
- package/src/proxmox/fake-pbs-notify.ts +108 -0
- package/src/proxmox/fake-pve.ts +12 -2
- package/src/proxmox/generated/pbs.ts +329 -0
- package/src/proxmox/generated/pve.ts +62 -0
- package/src/proxmox/index.test.ts +3 -0
- package/src/proxmox/index.ts +18 -0
- package/src/proxmox/notification-matcher-form.ts +221 -0
- package/src/proxmox/notification-matcher.test.ts +234 -0
- package/src/proxmox/notification-matcher.ts +76 -0
- package/src/proxmox/pbs-notification-matcher.ts +81 -0
- package/src/proxmox/pbs-notification-target-drift.test.ts +83 -0
- package/src/proxmox/pbs-notification-target-form.ts +229 -0
- package/src/proxmox/pbs-notification-target-lifecycle.ts +177 -0
- package/src/proxmox/pbs-notification-target-state.test.ts +191 -0
- package/src/proxmox/pbs-notification-target-wire.ts +112 -0
- package/src/proxmox/pbs-notification-target.test.ts +226 -0
- package/src/proxmox/pbs-notification-target.ts +139 -0
- package/src/proxmox/pbs-notifications-stack.test.ts +94 -0
- package/src/proxmox/resource.ts +6 -6
- package/src/proxmox/write-only.test.ts +75 -0
- package/src/proxmox/write-only.ts +106 -0
- package/src/verify/fake-engine.ts +8 -1
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `declareRepoPolicy` — the policy from repo-policy-form.ts, as resources.
|
|
3
|
+
*
|
|
4
|
+
* const policy = yield* declareRepoPolicy('api', {
|
|
5
|
+
* owner: 'my-org',
|
|
6
|
+
* repository: 'api',
|
|
7
|
+
* checks: ['ci', 'secret scan'],
|
|
8
|
+
* });
|
|
9
|
+
*
|
|
10
|
+
* Provide `GitHub.providers()`; this subpath adds no provider of its own.
|
|
11
|
+
*
|
|
12
|
+
* ⛔ THE RULESET CANNOT BE ADOPTED, AND A FIRST DEPLOY ONTO A REPO THAT ALREADY HAS ONE
|
|
13
|
+
* MAKES A SECOND. Read out of alchemy@2.0.0-beta.79's own source on 2026-09-22
|
|
14
|
+
* (`node_modules/alchemy/lib/github/Ruleset.js`): `read` returns `undefined` whenever
|
|
15
|
+
* there is no prior output, and `reconcile` looks the live ruleset up only BY THE ID IN
|
|
16
|
+
* THAT OUTPUT — with none, it calls `createRepoRuleset` unconditionally. GitHub permits
|
|
17
|
+
* several rulesets with the same name on one repository, so nothing errors; the repo
|
|
18
|
+
* quietly ends up with two, both enforcing.
|
|
19
|
+
* ★ SO, BEFORE THE FIRST DEPLOY: `gh api repos/<owner>/<repo>/rulesets` and delete a
|
|
20
|
+
* hand-made ruleset of the same name, or give this one a `rulesetName` of its own.
|
|
21
|
+
* Afterwards the state carries the id and every later deploy converges that one.
|
|
22
|
+
* ⚠️ `GitHub.Repository` does NOT share the problem — its `reconcile` probes by name
|
|
23
|
+
* and converges onto whatever is live — so only half of this helper is adopt-safe,
|
|
24
|
+
* and the halves fail differently. See docs/repo-policy.md.
|
|
25
|
+
*
|
|
26
|
+
* ⛔ BOTH RESOURCES RETAIN. `retain` is already the vendor default for each of them; it
|
|
27
|
+
* is piped anyway so that a vendor change cannot quietly turn "drop this call from the
|
|
28
|
+
* stack" into "delete the repository" or "unprotect the default branch".
|
|
29
|
+
*/
|
|
30
|
+
import { adopt } from 'alchemy/AdoptPolicy';
|
|
31
|
+
import * as GitHub from 'alchemy/GitHub';
|
|
32
|
+
import * as RemovalPolicy from 'alchemy/RemovalPolicy';
|
|
33
|
+
import * as Effect from 'effect/Effect';
|
|
34
|
+
import { type RepoPolicyOptions, repoPolicy } from './repo-policy-form.ts';
|
|
35
|
+
|
|
36
|
+
export interface DeclareRepoPolicyOptions extends RepoPolicyOptions {
|
|
37
|
+
/**
|
|
38
|
+
* `true`/`false` pipe `adopt(…)` onto both resources; omitted, the deploy's own policy
|
|
39
|
+
* decides. ⚠️ It changes nothing about the ruleset hazard above: adoption routes on
|
|
40
|
+
* what a provider's `read` reports, and this one reports nothing without prior state.
|
|
41
|
+
*/
|
|
42
|
+
readonly adopt?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Declares the repository and its ruleset under `<id>` and `<id>-ruleset`. Fails, before
|
|
47
|
+
* declaring either, on any policy the form refuses.
|
|
48
|
+
*/
|
|
49
|
+
export const declareRepoPolicy = (id: string, options: DeclareRepoPolicyOptions) =>
|
|
50
|
+
Effect.gen(function* () {
|
|
51
|
+
const policy = yield* Effect.try({
|
|
52
|
+
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
|
|
53
|
+
try: () => repoPolicy(options),
|
|
54
|
+
});
|
|
55
|
+
// ⚠️ `never` in the error channel, not a generic `E`: `AdoptPolicy.adopt` is typed
|
|
56
|
+
// `Effect<A, never, R> => Effect<A, never, R>`, so a wider error type fails to
|
|
57
|
+
// compile here rather than at the call site. Alchemy's resources declare `never`.
|
|
58
|
+
const owned = <A, R>(resource: Effect.Effect<A, never, R>) => {
|
|
59
|
+
const kept = resource.pipe(RemovalPolicy.retain());
|
|
60
|
+
return options.adopt === undefined ? kept : kept.pipe(adopt(options.adopt));
|
|
61
|
+
};
|
|
62
|
+
const repository = yield* owned(GitHub.Repository(id, policy.repository));
|
|
63
|
+
const ruleset = yield* owned(GitHub.Ruleset(`${id}-ruleset`, policy.ruleset));
|
|
64
|
+
return { policy, repository, ruleset };
|
|
65
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* import { R2BucketLock } from '@homeflare/alchemy/cloudflare';
|
|
8
8
|
* import { BranchProtection } from '@homeflare/alchemy/forgejo';
|
|
9
|
+
* import { declareRepoPolicy } from '@homeflare/alchemy/github';
|
|
9
10
|
* import { BaoMount } from '@homeflare/alchemy/openbao';
|
|
10
11
|
* import { TalosCluster } from '@homeflare/alchemy/talos';
|
|
11
12
|
* import { ProxmoxAcl } from '@homeflare/alchemy/proxmox';
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A PBS webhook body that posts one Alertmanager v2 alert — `POST /api/v2/alerts` takes a JSON
|
|
3
|
+
* ARRAY of `postableAlert`, each with a required `labels` map and an optional `annotations` map,
|
|
4
|
+
* every value a string (Alertmanager's api/v2/openapi.yaml, read at main 2026-09-22).
|
|
5
|
+
*
|
|
6
|
+
* ★ PURE TEXT, NOT A RENDERER. PBS renders it, with Handlebars, at send time: `{{ severity }}`,
|
|
7
|
+
* `{{ title }}`, `{{ message }}`, `{{ timestamp }}` and `{{ fields.<name> }}` are the
|
|
8
|
+
* notification, and `json`, `escape` and `url-encode` are the only helpers
|
|
9
|
+
* (proxmox-notify endpoints/webhook.rs `setup_handlebars`, read at HEAD 2026-09-22).
|
|
10
|
+
*
|
|
11
|
+
* ⛔ THERE IS NO ESCAPING UNLESS THE TEMPLATE ASKS FOR IT. The webhook's Handlebars registers
|
|
12
|
+
* `no_escape` ("There is no escape."), so `"{{ message }}"` breaks the JSON the first time a
|
|
13
|
+
* task log holds a quote or a newline — and a failed job's message is exactly a task log. Every
|
|
14
|
+
* value below goes through `json`, which emits a complete, quoted JSON string.
|
|
15
|
+
* ⛔ A FIELD THE NOTIFICATION DOES NOT CARRY MUST NOT BE REACHED FOR BARE. `escape` on a missing
|
|
16
|
+
* field fails the whole render, and the notification is then lost — not degraded. MEASURED
|
|
17
|
+
* (2026-09-22) with handlebars-rust 5.1.2 and the webhook's three helpers restated from source:
|
|
18
|
+
* `{{ escape fields.job-id }}` over no fields errors "param at index 0 required but not found";
|
|
19
|
+
* `{{ json fields.job-id }}` renders `null`, valid JSON but not a label value. Garbage
|
|
20
|
+
* collection has no `job-id`, and the UI's "Test" button sends a notification with NO fields at
|
|
21
|
+
* all (lib.rs `test_target`). So every field-derived label sits inside
|
|
22
|
+
* `{{#if}}`, ends with its own comma, and the labels after them are always there: whichever
|
|
23
|
+
* fields are present, the object stays valid JSON.
|
|
24
|
+
*
|
|
25
|
+
* ⚠️ ALERTMANAGER RESOLVES IT BY ITSELF. With no `endsAt` it sets `now + resolve_timeout` (5m by
|
|
26
|
+
* default, api/v2/api.go `postAlertsHandler`), and PBS never sends a resolve — so a receiver
|
|
27
|
+
* with `send_resolved: true` reports "resolved" five minutes after a failure that nobody fixed.
|
|
28
|
+
* Page on the firing notification; do not read the resolve as a recovery.
|
|
29
|
+
* ⚠️ `severity` IS PBS'S WORD, not Alertmanager convention: `info`, `notice`, `warning`, `error`,
|
|
30
|
+
* `unknown`. Route on `severity="error"`, or relabel it in the Alertmanager config.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
export interface AlertmanagerBodyOptions {
|
|
34
|
+
/** The `alertname` label. Default `PbsNotification`; `job_type` says which job it was. */
|
|
35
|
+
readonly alertname?: string;
|
|
36
|
+
/** The `source` label. Default `pbs`. */
|
|
37
|
+
readonly source?: string;
|
|
38
|
+
/** Optional `generatorURL` — an absolute link back to the PBS UI. */
|
|
39
|
+
readonly generatorURL?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* ⛔ AN OPTION IS PASTED INTO THE TEMPLATE, SO IT MAY NOT CARRY TEMPLATE SYNTAX. A `{{` in an
|
|
44
|
+
* alert name would be rendered by PBS as an expression; a brace or backslash is refused here
|
|
45
|
+
* rather than escaped, because Handlebars' escape rules are the renderer's, not this file's.
|
|
46
|
+
*/
|
|
47
|
+
const literal = (label: string, value: string): string => {
|
|
48
|
+
if (/[{}\\]/.test(value) || value === '') {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`alertmanagerAlertBody: ${label} must be non-empty and hold no '{', '}' or '\\'`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return JSON.stringify(value);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* ⛔ AN ABSOLUTE http(s) URL OR NOTHING. Alertmanager validates `generatorURL` as `format: uri`
|
|
58
|
+
* (api/v2/openapi.yaml) and answers 422 for the WHOLE post — so a typo here would not degrade
|
|
59
|
+
* one field, it would lose every notification, and only at send time.
|
|
60
|
+
*/
|
|
61
|
+
const absoluteUrl = (value: string): string => {
|
|
62
|
+
const text = literal('generatorURL', value);
|
|
63
|
+
let protocol = '';
|
|
64
|
+
try {
|
|
65
|
+
protocol = new URL(value).protocol;
|
|
66
|
+
} catch {
|
|
67
|
+
// not a URL at all: refused below, like a relative one
|
|
68
|
+
}
|
|
69
|
+
if (protocol !== 'https:' && protocol !== 'http:') {
|
|
70
|
+
throw new Error('alertmanagerAlertBody: generatorURL must be an absolute http(s) URL');
|
|
71
|
+
}
|
|
72
|
+
return text;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** Label name → notification field. ⚠️ Label names must match `[a-zA-Z_][a-zA-Z0-9_]*`. */
|
|
76
|
+
const FIELD_LABELS: readonly (readonly [label: string, field: string])[] = [
|
|
77
|
+
['job_type', 'type'],
|
|
78
|
+
['job_id', 'job-id'],
|
|
79
|
+
['datastore', 'datastore'],
|
|
80
|
+
['hostname', 'hostname'],
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The body template. Declare it on a webhook target together with `Content-Type:
|
|
85
|
+
* application/json`, method `post`, and the URL `<alertmanager>/api/v2/alerts`.
|
|
86
|
+
*
|
|
87
|
+
* ★ `fields.job-id` IS A VALID PATH: Handlebars-rust 5 (the version proxmox-notify pins) allows
|
|
88
|
+
* `-` in an identifier (grammar.pest `symbol_char`), as handlebars.js does. The same scratch
|
|
89
|
+
* build rendered this template for a GC failure, a verify failure and a field-less test
|
|
90
|
+
* notification, and each parsed as JSON with string-only labels. The adversarial review re-ran
|
|
91
|
+
* it over the test file's whole matrix — every PBS event's fields x five severities x hostile
|
|
92
|
+
* text, 840 renders with and without `generatorURL` — and every one parsed (2026-09-22).
|
|
93
|
+
* ⚠️ THAT BUILD IS NOT PBS. The first real notification is the measurement that counts: send one
|
|
94
|
+
* with the target's "Test" button and read what Alertmanager received.
|
|
95
|
+
*/
|
|
96
|
+
export const alertmanagerAlertBody = (options: AlertmanagerBodyOptions = {}): string => {
|
|
97
|
+
const alertname = literal('alertname', options.alertname ?? 'PbsNotification');
|
|
98
|
+
const source = literal('source', options.source ?? 'pbs');
|
|
99
|
+
const labels = FIELD_LABELS.map(
|
|
100
|
+
([label, field]) =>
|
|
101
|
+
` {{#if fields.${field}}}"${label}": {{ json fields.${field} }},{{/if}}`,
|
|
102
|
+
);
|
|
103
|
+
const link =
|
|
104
|
+
options.generatorURL === undefined
|
|
105
|
+
? ''
|
|
106
|
+
: `,\n "generatorURL": ${absoluteUrl(options.generatorURL)}`;
|
|
107
|
+
return [
|
|
108
|
+
'[',
|
|
109
|
+
' {',
|
|
110
|
+
' "labels": {',
|
|
111
|
+
...labels,
|
|
112
|
+
' "severity": {{ json severity }},',
|
|
113
|
+
` "alertname": ${alertname},`,
|
|
114
|
+
` "source": ${source}`,
|
|
115
|
+
' },',
|
|
116
|
+
' "annotations": {',
|
|
117
|
+
' "summary": {{ json title }},',
|
|
118
|
+
' "description": {{ json message }},',
|
|
119
|
+
' "timestamp": "{{ timestamp }}"',
|
|
120
|
+
` }${link}`,
|
|
121
|
+
' }',
|
|
122
|
+
']',
|
|
123
|
+
'',
|
|
124
|
+
].join('\n');
|
|
125
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A literal credential in a plain `Pbs.NotificationTarget` prop is refused — and refused BEFORE
|
|
3
|
+
* Alchemy writes the props to the state store, which is the whole point.
|
|
4
|
+
*
|
|
5
|
+
* ★ THE END-TO-END CASES RUN ALCHEMY'S OWN PLAN AND APPLY (verify/fake-engine.ts) and dump the
|
|
6
|
+
* store, because the failure being pinned is not "a refusal exists" but "the token never
|
|
7
|
+
* reached the row": before the fix a refused create had already committed its props.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, test } from 'bun:test';
|
|
10
|
+
import * as Layer from 'effect/Layer';
|
|
11
|
+
import { engineOver } from '../verify/fake-engine.ts';
|
|
12
|
+
import type { PbsTarget } from './credentials.ts';
|
|
13
|
+
import {
|
|
14
|
+
isCredentialName,
|
|
15
|
+
literalCredentialHeaders,
|
|
16
|
+
literalCredentialsInUrl,
|
|
17
|
+
} from './credential-literals.ts';
|
|
18
|
+
import { fakeNotify } from './fake-pbs-notify.ts';
|
|
19
|
+
import { withoutBao } from './fake-pve.ts';
|
|
20
|
+
import {
|
|
21
|
+
PbsNotificationTarget,
|
|
22
|
+
type PbsNotificationTargetProps,
|
|
23
|
+
PbsNotificationTargetProvider,
|
|
24
|
+
} from './pbs-notification-target.ts';
|
|
25
|
+
import { refusals } from './pbs-notification-target-form.ts';
|
|
26
|
+
|
|
27
|
+
const PBS: PbsTarget = { api: 'https://pbs.test:8007/api2/json', mount: 'pbs-test', scheme: 'pbs' };
|
|
28
|
+
const LITERAL = 'lit-7a1c-not-real';
|
|
29
|
+
const AM = 'https://alertmanager.example.com/api/v2/alerts';
|
|
30
|
+
|
|
31
|
+
const hook = (over: Partial<PbsNotificationTargetProps> = {}): PbsNotificationTargetProps => ({
|
|
32
|
+
method: 'post',
|
|
33
|
+
name: 'alertmanager',
|
|
34
|
+
target: PBS,
|
|
35
|
+
type: 'webhook',
|
|
36
|
+
url: AM,
|
|
37
|
+
...over,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('which names are credentials', () => {
|
|
41
|
+
test('authorization, cookie and credential words — whole names or dash/underscore words', () => {
|
|
42
|
+
for (const name of ['Authorization', 'proxy-authorization', 'Cookie', 'X-Api-Key', 'api_key']) {
|
|
43
|
+
expect(isCredentialName(name)).toBe(true);
|
|
44
|
+
}
|
|
45
|
+
for (const name of ['X-Auth-Token', 'Private-Token', 'X-Gotify-Key', 'token', 'sig', 'code']) {
|
|
46
|
+
expect(isCredentialName(name)).toBe(true);
|
|
47
|
+
}
|
|
48
|
+
for (const name of ['Content-Type', 'X-Scope-OrgID', 'Author', 'Keyword', 'Passthrough']) {
|
|
49
|
+
expect(isCredentialName(name)).toBe(false);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('literal credentials are found by where they sit', () => {
|
|
55
|
+
test('headers: a literal is refused; a secrets template, `{ fromEnv }` and `""` are not', () => {
|
|
56
|
+
expect(
|
|
57
|
+
literalCredentialHeaders({
|
|
58
|
+
'Content-Type': 'application/json',
|
|
59
|
+
Cookie: `session=${LITERAL}`,
|
|
60
|
+
'X-Api-Key': { fromEnv: 'HOOK_KEY' },
|
|
61
|
+
Authorization: `Bearer ${LITERAL}`,
|
|
62
|
+
'X-Auth-Token': '{{secrets.token}}',
|
|
63
|
+
'X-Empty-Token': '',
|
|
64
|
+
}),
|
|
65
|
+
).toEqual(['Authorization', 'Cookie']);
|
|
66
|
+
expect(literalCredentialHeaders({ Authorization: 'Bearer {{ secrets.token }}' })).toEqual([]);
|
|
67
|
+
expect(literalCredentialHeaders(undefined)).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('a URL: a userinfo password and credential-named query parameters, by name', () => {
|
|
71
|
+
expect(literalCredentialsInUrl(`https://pbs:${LITERAL}@am.example.com/x`)).toEqual([
|
|
72
|
+
'userinfo',
|
|
73
|
+
]);
|
|
74
|
+
expect(literalCredentialsInUrl(`${AM}?token=${LITERAL}&team=ops&api_key=${LITERAL}`)).toEqual([
|
|
75
|
+
'?token',
|
|
76
|
+
'?api_key',
|
|
77
|
+
]);
|
|
78
|
+
expect(literalCredentialsInUrl(`${AM}?code=${LITERAL}#token=${LITERAL}`)).toEqual([
|
|
79
|
+
'?code',
|
|
80
|
+
'?token',
|
|
81
|
+
]);
|
|
82
|
+
expect(literalCredentialsInUrl(`${AM}#team=ops&sig=${LITERAL}`)).toEqual(['?sig']);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('a URL that references its secrets, or has no password, is clean', () => {
|
|
86
|
+
for (const url of [
|
|
87
|
+
AM,
|
|
88
|
+
`${AM}?team=ops`,
|
|
89
|
+
`${AM}?token={{ url-encode secrets.token }}`,
|
|
90
|
+
'https://pbs:{{ secrets.pw }}@am.example.com/',
|
|
91
|
+
'https://pbs@am.example.com/',
|
|
92
|
+
`${AM}?token=`,
|
|
93
|
+
undefined,
|
|
94
|
+
]) {
|
|
95
|
+
expect(literalCredentialsInUrl(url)).toEqual([]);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('refusals name the header or the URL part — never the value', () => {
|
|
100
|
+
const reasons = refusals(
|
|
101
|
+
hook({ header: { Authorization: `Bearer ${LITERAL}` }, url: `${AM}?token=${LITERAL}` }),
|
|
102
|
+
);
|
|
103
|
+
expect(reasons).toHaveLength(2);
|
|
104
|
+
expect(reasons[0]).toStartWith("header 'Authorization' holds a literal credential");
|
|
105
|
+
expect(reasons[1]).toStartWith("'url' ?token holds a literal credential");
|
|
106
|
+
expect(reasons.join(' ')).not.toContain(LITERAL);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('through Alchemy: refused at plan, before anything is stored or sent', () => {
|
|
111
|
+
const run = async (props: PbsNotificationTargetProps) => {
|
|
112
|
+
const fake = fakeNotify();
|
|
113
|
+
let failure = '';
|
|
114
|
+
let stored = '';
|
|
115
|
+
await withoutBao(async () => {
|
|
116
|
+
const engine = engineOver(
|
|
117
|
+
PbsNotificationTargetProvider().pipe(Layer.provideMerge(fake.layer)),
|
|
118
|
+
);
|
|
119
|
+
await engine.deploy(PbsNotificationTarget('alertmanager', props)).catch((error: unknown) => {
|
|
120
|
+
failure = String(error);
|
|
121
|
+
});
|
|
122
|
+
stored = engine.stored();
|
|
123
|
+
});
|
|
124
|
+
return { failure, stored, writes: fake.writes() };
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
test('a literal Authorization header on a NEW target never reaches the store', async () => {
|
|
128
|
+
const { failure, stored, writes } = await run(
|
|
129
|
+
hook({ header: { Authorization: `Bearer ${LITERAL}` } }),
|
|
130
|
+
);
|
|
131
|
+
expect(failure).toContain("header 'Authorization' holds a literal credential");
|
|
132
|
+
expect(stored).not.toContain(LITERAL);
|
|
133
|
+
expect(writes).toEqual([]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('a literal query token on a NEW target never reaches the store', async () => {
|
|
137
|
+
const { failure, stored, writes } = await run(hook({ url: `${AM}?token=${LITERAL}` }));
|
|
138
|
+
expect(failure).toContain("'url' ?token holds a literal credential");
|
|
139
|
+
expect(stored).not.toContain(LITERAL);
|
|
140
|
+
expect(writes).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('any other refusal on a new target also fails before the row is written', async () => {
|
|
144
|
+
const { failure, stored } = await run(hook({ comment: LITERAL, mailto: ['x@example.com'] }));
|
|
145
|
+
expect(failure).toContain("'mailto' belongs to sendmail/smtp targets, not webhook");
|
|
146
|
+
expect(stored).not.toContain(LITERAL);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A credential written as a LITERAL in a plain prop — refused, because a plain prop is a value in
|
|
3
|
+
* the state store.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ THE HOLE THIS CLOSES, MEASURED 2026-09-22 (adversarial review of PR 95). write-only.ts keeps
|
|
6
|
+
* every `{ fromEnv }` value out of state, and the doc WARNED that a plain header value is a prop.
|
|
7
|
+
* A warning is not a guard: `header: { Authorization: 'Bearer <token>' }` planned, deployed, and
|
|
8
|
+
* left the token in the dumped state store — and so did `url: 'https://…?token=<token>'`. Alchemy
|
|
9
|
+
* stores props unencrypted (write-only.ts has the StateEncoding reading), so the only safe
|
|
10
|
+
* literal is one that is not a credential.
|
|
11
|
+
*
|
|
12
|
+
* ★ WHAT COUNTS AS A CREDENTIAL IS A NAME, NOT A VALUE. A value cannot be recognised — a token is
|
|
13
|
+
* any string — but where it sits can: an `Authorization`/`Cookie` header, a header or query
|
|
14
|
+
* parameter whose name has a word like `token`, `key`, `secret`, `password` or `signature`, and a
|
|
15
|
+
* password in the URL's userinfo. A false positive costs one edit (declare it `{ fromEnv }`, or
|
|
16
|
+
* hold it in `secret` and write `{{ secrets.<name> }}`); a false negative costs a leaked token.
|
|
17
|
+
* ★ A TEMPLATE THAT READS A SECRET IS NOT A LITERAL. `Bearer {{ secrets.token }}`, and
|
|
18
|
+
* `{{ url-encode secrets.token }}` in a query, render on PBS from the root-only secret store —
|
|
19
|
+
* the prop holds the reference, not the value.
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ WHAT THIS CANNOT SEE: a token in a URL PATH (a Slack-style `/services/T…/B…/<token>`), in the
|
|
22
|
+
* `body`, or in a `comment`. Those are plain props too; put the token in `secret` and reference
|
|
23
|
+
* it from the template. docs/pbs-notifications.md says the same to the reader.
|
|
24
|
+
* ⛔ A REFUSAL NAMES THE FIELD, NEVER THE VALUE — the message lands in a terminal and a CI log.
|
|
25
|
+
*/
|
|
26
|
+
import type { FromEnv } from './write-only.ts';
|
|
27
|
+
|
|
28
|
+
/** `{{ secrets.x }}`, `{{secrets.x}}`, `{{ url-encode secrets.x }}` — a reference, not a value. */
|
|
29
|
+
const READS_SECRET = /\{\{[^}]*\bsecrets\./;
|
|
30
|
+
|
|
31
|
+
/** A whole-name match, or one dash/underscore-separated word of the name. */
|
|
32
|
+
const CREDENTIAL_WORD =
|
|
33
|
+
/(^|[-_])(api[-_]?key|apikey|key|token|secret|password|passwd|pwd|pass|auth|signature|sig|code)($|[-_])/i;
|
|
34
|
+
const CREDENTIAL_HEADER = /^(proxy-authorization|authorization|cookie)$/i;
|
|
35
|
+
|
|
36
|
+
export const isCredentialName = (name: string): boolean =>
|
|
37
|
+
CREDENTIAL_HEADER.test(name) || CREDENTIAL_WORD.test(name);
|
|
38
|
+
|
|
39
|
+
const literal = (value: string): boolean => value !== '' && !READS_SECRET.test(value);
|
|
40
|
+
|
|
41
|
+
/** Plain header values that are credentials by their header's name. `{ fromEnv }` is never one. */
|
|
42
|
+
export const literalCredentialHeaders = (
|
|
43
|
+
header: Readonly<Record<string, FromEnv | string>> | undefined,
|
|
44
|
+
): string[] =>
|
|
45
|
+
Object.entries(header ?? {})
|
|
46
|
+
.filter(([name, value]) => typeof value === 'string' && isCredentialName(name))
|
|
47
|
+
.filter(([, value]) => literal(value as string))
|
|
48
|
+
.map(([name]) => name)
|
|
49
|
+
.sort();
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Where a URL carries a literal credential: `userinfo` (a password before the `@`), or the names
|
|
53
|
+
* of credential-shaped query (or fragment) parameters. Parsed by hand, not with `new URL`: a URL here is a
|
|
54
|
+
* TEMPLATE, and `{{ … }}` in the host or the userinfo is not a URL `new URL` will accept.
|
|
55
|
+
*/
|
|
56
|
+
export const literalCredentialsInUrl = (url: string | undefined): string[] => {
|
|
57
|
+
if (url === undefined) return [];
|
|
58
|
+
const out: string[] = [];
|
|
59
|
+
const authority = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]*)/i.exec(url)?.[1] ?? '';
|
|
60
|
+
const at = authority.lastIndexOf('@');
|
|
61
|
+
if (at >= 0) {
|
|
62
|
+
const userinfo = authority.slice(0, at);
|
|
63
|
+
const colon = userinfo.indexOf(':');
|
|
64
|
+
if (colon >= 0 && literal(userinfo.slice(colon + 1))) out.push('userinfo');
|
|
65
|
+
}
|
|
66
|
+
// ⚠️ THE FRAGMENT TOO: it is never sent, but it is still a prop — and so still in state.
|
|
67
|
+
const params = url.replace(/^[^?#]*/, '');
|
|
68
|
+
for (const pair of params.split(/[?&#]/)) {
|
|
69
|
+
const eq = pair.indexOf('=');
|
|
70
|
+
const name = eq < 0 ? pair : pair.slice(0, eq);
|
|
71
|
+
if (eq >= 0 && isCredentialName(name) && literal(pair.slice(eq + 1))) out.push(`?${name}`);
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A PBS (or PVE) notification config that remembers writes — enough of proxmox-notify's CRUD for
|
|
3
|
+
* a family's REAL provider to create, drift, rotate and adopt through Alchemy's engine.
|
|
4
|
+
*
|
|
5
|
+
* ★ STATEFUL, UNLIKE `fakePve`'s bare answer function, because the questions here are about the
|
|
6
|
+
* SECOND deploy: whether a rotated secret is written, whether an unchanged one is not. That needs
|
|
7
|
+
* a server that holds what the first deploy wrote.
|
|
8
|
+
* ★ IT KEEPS proxmox-notify's TWO RULES THAT MATTER HERE (api/webhook.rs, read at HEAD 2026-09-22):
|
|
9
|
+
* 1. a read returns secret NAMES only; values live in a separate private store;
|
|
10
|
+
* 2. a PUT that carries `secret` REPLACES the list, a name without a value keeping the old
|
|
11
|
+
* one; a PUT without `secret` leaves the list alone. `delete` entries clear a field.
|
|
12
|
+
* Repeated form keys arrive as arrays, as the real urlencoded decoder builds them.
|
|
13
|
+
* ⛔ TEST-ONLY. No provider imports this file.
|
|
14
|
+
*/
|
|
15
|
+
import { type FakePve, type PveCall, fakePve } from './fake-pve.ts';
|
|
16
|
+
import { keyAndValue } from './pbs-notification-target-wire.ts';
|
|
17
|
+
|
|
18
|
+
/** Keys the servers take as `type: array`. Everything else is a scalar. */
|
|
19
|
+
const LISTS = new Set([
|
|
20
|
+
'header',
|
|
21
|
+
'mailto',
|
|
22
|
+
'mailto-user',
|
|
23
|
+
'match-calendar',
|
|
24
|
+
'match-field',
|
|
25
|
+
'match-severity',
|
|
26
|
+
'secret',
|
|
27
|
+
'target',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
export interface FakeNotify extends FakePve {
|
|
31
|
+
/** Public config by path, as a GET would see it apart from secrets. Mutable, for drift. */
|
|
32
|
+
readonly objects: Map<string, Record<string, unknown>>;
|
|
33
|
+
/** Secret items with values (`name=…,value=<base64>`), by path — the root-only file. */
|
|
34
|
+
readonly secrets: Map<string, string[]>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const body = (call: PveCall) => {
|
|
38
|
+
const fields: Record<string, unknown> = {};
|
|
39
|
+
const deletes: string[] = [];
|
|
40
|
+
for (const [key, value] of call.pairs) {
|
|
41
|
+
if (key === 'delete') deletes.push(value);
|
|
42
|
+
else if (LISTS.has(key))
|
|
43
|
+
fields[key] = [...((fields[key] as string[] | undefined) ?? []), value];
|
|
44
|
+
// ⚠️ Scalars as the server's JSON reads them back: a flag as a boolean, a port as a number.
|
|
45
|
+
else if (key === 'disable' || key === 'invert-match') fields[key] = value === '1';
|
|
46
|
+
else if (key === 'port') fields[key] = Number(value);
|
|
47
|
+
else fields[key] = value;
|
|
48
|
+
}
|
|
49
|
+
return { deletes, fields };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const withSecretsBlanked = (
|
|
53
|
+
path: string,
|
|
54
|
+
object: Record<string, unknown>,
|
|
55
|
+
secrets: FakeNotify['secrets'],
|
|
56
|
+
) => {
|
|
57
|
+
const held = secrets.get(path);
|
|
58
|
+
return held === undefined || held.length === 0
|
|
59
|
+
? object
|
|
60
|
+
: { ...object, secret: held.map((item) => `name=${keyAndValue(item).name}`) };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const fakeNotify = (
|
|
64
|
+
seed: Readonly<Record<string, Record<string, unknown>>> = {},
|
|
65
|
+
): FakeNotify => {
|
|
66
|
+
const objects = new Map(Object.entries(seed).map(([path, object]) => [path, { ...object }]));
|
|
67
|
+
const secrets = new Map<string, string[]>();
|
|
68
|
+
|
|
69
|
+
const putSecrets = (path: string, items: readonly string[]) => {
|
|
70
|
+
const old = secrets.get(path) ?? [];
|
|
71
|
+
const next = items.map((item) => {
|
|
72
|
+
const { name, value } = keyAndValue(item);
|
|
73
|
+
if (value !== undefined) return item;
|
|
74
|
+
const kept = old.find((existing) => keyAndValue(existing).name === name);
|
|
75
|
+
if (kept === undefined) throw new Error(`secret '${name}' not known`);
|
|
76
|
+
return kept;
|
|
77
|
+
});
|
|
78
|
+
secrets.set(path, next);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const fake = fakePve((call) => {
|
|
82
|
+
const path = call.path.split('?')[0] ?? call.path;
|
|
83
|
+
const { deletes, fields } = body(call);
|
|
84
|
+
if (call.method === 'GET') {
|
|
85
|
+
const object = objects.get(path);
|
|
86
|
+
return object === undefined ? undefined : withSecretsBlanked(path, object, secrets);
|
|
87
|
+
}
|
|
88
|
+
if (call.method === 'DELETE') {
|
|
89
|
+
objects.delete(path);
|
|
90
|
+
secrets.delete(path);
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
const target = call.method === 'POST' ? `${path}/${String(fields['name'])}` : path;
|
|
94
|
+
const { secret, ...rest } = fields as { secret?: string[] } & Record<string, unknown>;
|
|
95
|
+
const next: Record<string, unknown> = {
|
|
96
|
+
...(call.method === 'PUT' ? objects.get(target) : {}),
|
|
97
|
+
...rest,
|
|
98
|
+
};
|
|
99
|
+
for (const key of deletes) {
|
|
100
|
+
if (key === 'secret') secrets.delete(target);
|
|
101
|
+
else delete next[key];
|
|
102
|
+
}
|
|
103
|
+
objects.set(target, next);
|
|
104
|
+
if (secret !== undefined) putSecrets(target, secret);
|
|
105
|
+
return undefined;
|
|
106
|
+
});
|
|
107
|
+
return { ...fake, objects, secrets };
|
|
108
|
+
};
|
package/src/proxmox/fake-pve.ts
CHANGED
|
@@ -28,6 +28,11 @@ export interface PveCall {
|
|
|
28
28
|
/** Relative to `/api2/json/`, query included — the string a family passes to `pve()`. */
|
|
29
29
|
readonly path: string;
|
|
30
30
|
readonly form: Readonly<Record<string, string>>;
|
|
31
|
+
/**
|
|
32
|
+
* Every key/value pair in wire order. ⚠️ `form` keeps only the LAST value of a repeated key, and
|
|
33
|
+
* a PBS list IS a repeated key (client.ts `encode`) — assert a list on `pairs`, never `form`.
|
|
34
|
+
*/
|
|
35
|
+
readonly pairs: readonly (readonly [string, string])[];
|
|
31
36
|
}
|
|
32
37
|
|
|
33
38
|
/** Answers one call with its `data`; `undefined` is PVE's `{"data": null}`. */
|
|
@@ -56,8 +61,13 @@ export const fakePve = (answer: PveAnswer): FakePve => {
|
|
|
56
61
|
return Response.json({ data, lease_duration: 0 });
|
|
57
62
|
}
|
|
58
63
|
const path = `${url.pathname.replace(/^\/api2\/json\//, '')}${url.search}`;
|
|
59
|
-
const
|
|
60
|
-
const call = {
|
|
64
|
+
const params = new URLSearchParams(await request.text());
|
|
65
|
+
const call = {
|
|
66
|
+
form: Object.fromEntries(params),
|
|
67
|
+
method: request.method,
|
|
68
|
+
pairs: [...params],
|
|
69
|
+
path,
|
|
70
|
+
};
|
|
61
71
|
calls.push(call);
|
|
62
72
|
return Response.json({ data: answer(call) ?? null });
|
|
63
73
|
};
|