@fusionkit/plane 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth.d.ts +18 -0
- package/dist/auth.js +46 -0
- package/dist/claim-token-service.d.ts +23 -0
- package/dist/claim-token-service.js +54 -0
- package/dist/contract-service.d.ts +14 -0
- package/dist/contract-service.js +39 -0
- package/dist/domain-errors.d.ts +13 -0
- package/dist/domain-errors.js +31 -0
- package/dist/idp.d.ts +26 -0
- package/dist/idp.js +24 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +21 -0
- package/dist/keys.d.ts +60 -0
- package/dist/keys.js +132 -0
- package/dist/logging.d.ts +21 -0
- package/dist/logging.js +42 -0
- package/dist/plane.d.ts +167 -0
- package/dist/plane.js +606 -0
- package/dist/policy.d.ts +23 -0
- package/dist/policy.js +92 -0
- package/dist/ratelimit.d.ts +40 -0
- package/dist/ratelimit.js +94 -0
- package/dist/receipt-service.d.ts +16 -0
- package/dist/receipt-service.js +17 -0
- package/dist/retention.d.ts +33 -0
- package/dist/retention.js +123 -0
- package/dist/run-lifecycle.d.ts +2 -0
- package/dist/run-lifecycle.js +19 -0
- package/dist/secrets.d.ts +25 -0
- package/dist/secrets.js +73 -0
- package/dist/server.d.ts +38 -0
- package/dist/server.js +418 -0
- package/dist/sqlite-store.d.ts +53 -0
- package/dist/sqlite-store.js +401 -0
- package/dist/store.d.ts +107 -0
- package/dist/store.js +9 -0
- package/dist/test/api.test.d.ts +1 -0
- package/dist/test/api.test.js +179 -0
- package/dist/test/hardening.test.d.ts +1 -0
- package/dist/test/hardening.test.js +259 -0
- package/dist/test/policy.test.d.ts +1 -0
- package/dist/test/policy.test.js +78 -0
- package/dist/test/server-hardening.test.d.ts +1 -0
- package/dist/test/server-hardening.test.js +192 -0
- package/dist/test/ui-parity.test.d.ts +1 -0
- package/dist/test/ui-parity.test.js +28 -0
- package/dist/validation.d.ts +326 -0
- package/dist/validation.js +178 -0
- package/package.json +34 -0
- package/ui/app.css +276 -0
- package/ui/app.js +483 -0
- package/ui/index.html +65 -0
package/dist/policy.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { PolicyDeniedError } from "@fusionkit/protocol";
|
|
2
|
+
/**
|
|
3
|
+
* Evaluate a run request against policy at contract time. Fail closed:
|
|
4
|
+
* anything not allowed throws PolicyDeniedError; anything allowed but
|
|
5
|
+
* matching a consent rule returns "ask" with the named requirements.
|
|
6
|
+
*/
|
|
7
|
+
export function evaluatePolicy(policy, request) {
|
|
8
|
+
const denials = [];
|
|
9
|
+
if (!policy.agents.allow.includes(request.agentKind)) {
|
|
10
|
+
denials.push(`agent kind "${request.agentKind}" is not allowed`);
|
|
11
|
+
}
|
|
12
|
+
if (!policy.runners.allowPools.includes(request.pool)) {
|
|
13
|
+
denials.push(`runner pool "${request.pool}" is not allowed`);
|
|
14
|
+
}
|
|
15
|
+
for (const name of request.secretNames) {
|
|
16
|
+
const rule = policy.secrets.releasable.find((r) => r.name === name);
|
|
17
|
+
if (!rule) {
|
|
18
|
+
denials.push(`secret "${name}" is not releasable under policy`);
|
|
19
|
+
}
|
|
20
|
+
else if (!rule.pools.includes(request.pool)) {
|
|
21
|
+
denials.push(`secret "${name}" is not releasable to pool "${request.pool}"`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (policy.network.defaultDeny) {
|
|
25
|
+
const allowed = new Set(policy.network.allowHosts);
|
|
26
|
+
for (const host of request.allowHosts) {
|
|
27
|
+
if (!allowed.has(host)) {
|
|
28
|
+
denials.push(`network host "${host}" is not in the policy allowlist`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const spend = request.maxSpendUsd ?? policy.budget.maxSpendUsd;
|
|
33
|
+
if (spend > policy.budget.maxSpendUsd) {
|
|
34
|
+
denials.push(`requested budget $${spend} exceeds policy ceiling $${policy.budget.maxSpendUsd}`);
|
|
35
|
+
}
|
|
36
|
+
const duration = request.maxDurationMin ?? policy.budget.maxDurationMin;
|
|
37
|
+
if (duration > policy.budget.maxDurationMin) {
|
|
38
|
+
denials.push(`requested duration ${duration}m exceeds policy ceiling ${policy.budget.maxDurationMin}m`);
|
|
39
|
+
}
|
|
40
|
+
if (denials.length > 0) {
|
|
41
|
+
throw new PolicyDeniedError(denials);
|
|
42
|
+
}
|
|
43
|
+
const consentRequirements = [];
|
|
44
|
+
for (const rule of policy.consent) {
|
|
45
|
+
switch (rule.when) {
|
|
46
|
+
case "any-run":
|
|
47
|
+
consentRequirements.push("any-run");
|
|
48
|
+
break;
|
|
49
|
+
case "secret-release":
|
|
50
|
+
if (request.secretNames.length > 0) {
|
|
51
|
+
consentRequirements.push(`secret-release:${request.secretNames.join(",")}`);
|
|
52
|
+
}
|
|
53
|
+
break;
|
|
54
|
+
case "agent-kind":
|
|
55
|
+
if (rule.match === request.agentKind) {
|
|
56
|
+
consentRequirements.push(`agent-kind:${request.agentKind}`);
|
|
57
|
+
}
|
|
58
|
+
break;
|
|
59
|
+
default: {
|
|
60
|
+
const exhausted = rule.when;
|
|
61
|
+
throw new Error(`unreachable consent rule: ${String(exhausted)}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (consentRequirements.length > 0) {
|
|
66
|
+
return {
|
|
67
|
+
decision: "ask",
|
|
68
|
+
reason: `consent required: ${consentRequirements.join("; ")}`,
|
|
69
|
+
consentRequirements
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return { decision: "allow", reason: "policy allows this run", consentRequirements: [] };
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Opinionated starter policy for `warrant init` and the in-process test/demo
|
|
76
|
+
* stacks. Production deployments edit `policy.json` (or supply their own
|
|
77
|
+
* Policy); these defaults are intentionally permissive-but-bounded for a
|
|
78
|
+
* single-node dev/demo setup, not a recommended production posture.
|
|
79
|
+
*/
|
|
80
|
+
export function defaultPolicy() {
|
|
81
|
+
return {
|
|
82
|
+
version: "warrant.policy.v1",
|
|
83
|
+
runners: { allowPools: ["default"] },
|
|
84
|
+
agents: { allow: ["claude-code", "codex", "pi", "mock", "command"] },
|
|
85
|
+
dataClasses: [],
|
|
86
|
+
network: { defaultDeny: true, allowHosts: [] },
|
|
87
|
+
secrets: { releasable: [] },
|
|
88
|
+
budget: { maxSpendUsd: 25, maxDurationMin: 60 },
|
|
89
|
+
consent: [],
|
|
90
|
+
retention: { receiptsDays: 365, artifactsDays: 90 }
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory token-bucket rate limiter plus failed-auth backoff. Per-key
|
|
3
|
+
* (principal id or client IP). In-memory is correct for a single-node
|
|
4
|
+
* plane; a multi-node deployment would back this with the shared store,
|
|
5
|
+
* which is why the limiter is injected rather than global.
|
|
6
|
+
*/
|
|
7
|
+
export type RateLimitConfig = {
|
|
8
|
+
/** Sustained requests per second per key. */
|
|
9
|
+
ratePerSec: number;
|
|
10
|
+
/** Maximum burst (bucket capacity). */
|
|
11
|
+
burst: number;
|
|
12
|
+
/** Lock a key out for this long after too many auth failures. */
|
|
13
|
+
authFailureWindowMs: number;
|
|
14
|
+
/** Auth failures within the window before lockout. */
|
|
15
|
+
authFailureLimit: number;
|
|
16
|
+
};
|
|
17
|
+
/** Tunable defaults; override per deployment via PlaneServerOptions.rateLimit. */
|
|
18
|
+
export declare const DEFAULT_RATE_LIMIT: RateLimitConfig;
|
|
19
|
+
/**
|
|
20
|
+
* In-memory token-bucket limiter. Correct for a single-node plane; a
|
|
21
|
+
* multi-node deployment would back this with the shared store (the limiter
|
|
22
|
+
* is injected, not global, so that swap is local). Idle entries are evicted
|
|
23
|
+
* once the maps grow large, so a long-lived process does not accumulate
|
|
24
|
+
* unbounded keys.
|
|
25
|
+
*/
|
|
26
|
+
export declare class RateLimiter {
|
|
27
|
+
private readonly buckets;
|
|
28
|
+
private readonly failures;
|
|
29
|
+
private readonly config;
|
|
30
|
+
private readonly now;
|
|
31
|
+
constructor(config?: Partial<RateLimitConfig>, now?: () => number);
|
|
32
|
+
/** Drop entries that have fully refilled / expired and are idle. */
|
|
33
|
+
private evictIdle;
|
|
34
|
+
/** Consume one token for `key`; returns false when the bucket is empty. */
|
|
35
|
+
allow(key: string): boolean;
|
|
36
|
+
/** Is the key currently locked out for repeated auth failures? */
|
|
37
|
+
isLockedOut(key: string): boolean;
|
|
38
|
+
recordAuthFailure(key: string): void;
|
|
39
|
+
recordAuthSuccess(key: string): void;
|
|
40
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory token-bucket rate limiter plus failed-auth backoff. Per-key
|
|
3
|
+
* (principal id or client IP). In-memory is correct for a single-node
|
|
4
|
+
* plane; a multi-node deployment would back this with the shared store,
|
|
5
|
+
* which is why the limiter is injected rather than global.
|
|
6
|
+
*/
|
|
7
|
+
/** Tunable defaults; override per deployment via PlaneServerOptions.rateLimit. */
|
|
8
|
+
export const DEFAULT_RATE_LIMIT = {
|
|
9
|
+
ratePerSec: 50,
|
|
10
|
+
burst: 100,
|
|
11
|
+
authFailureWindowMs: 60_000,
|
|
12
|
+
authFailureLimit: 20
|
|
13
|
+
};
|
|
14
|
+
/** Evict idle bucket/failure entries once the map grows past this size. */
|
|
15
|
+
const EVICTION_THRESHOLD = 10_000;
|
|
16
|
+
/**
|
|
17
|
+
* In-memory token-bucket limiter. Correct for a single-node plane; a
|
|
18
|
+
* multi-node deployment would back this with the shared store (the limiter
|
|
19
|
+
* is injected, not global, so that swap is local). Idle entries are evicted
|
|
20
|
+
* once the maps grow large, so a long-lived process does not accumulate
|
|
21
|
+
* unbounded keys.
|
|
22
|
+
*/
|
|
23
|
+
export class RateLimiter {
|
|
24
|
+
buckets = new Map();
|
|
25
|
+
failures = new Map();
|
|
26
|
+
config;
|
|
27
|
+
now;
|
|
28
|
+
constructor(config = {}, now = Date.now) {
|
|
29
|
+
this.config = { ...DEFAULT_RATE_LIMIT, ...config };
|
|
30
|
+
this.now = now;
|
|
31
|
+
}
|
|
32
|
+
/** Drop entries that have fully refilled / expired and are idle. */
|
|
33
|
+
evictIdle(nowMs) {
|
|
34
|
+
if (this.buckets.size > EVICTION_THRESHOLD) {
|
|
35
|
+
for (const [key, bucket] of this.buckets) {
|
|
36
|
+
const elapsedSec = (nowMs - bucket.updatedMs) / 1000;
|
|
37
|
+
if (bucket.tokens + elapsedSec * this.config.ratePerSec >= this.config.burst) {
|
|
38
|
+
this.buckets.delete(key);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (this.failures.size > EVICTION_THRESHOLD) {
|
|
43
|
+
for (const [key, state] of this.failures) {
|
|
44
|
+
if (nowMs >= state.lockedUntilMs && nowMs - state.windowStartMs > this.config.authFailureWindowMs) {
|
|
45
|
+
this.failures.delete(key);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Consume one token for `key`; returns false when the bucket is empty. */
|
|
51
|
+
allow(key) {
|
|
52
|
+
const nowMs = this.now();
|
|
53
|
+
this.evictIdle(nowMs);
|
|
54
|
+
const bucket = this.buckets.get(key) ?? {
|
|
55
|
+
tokens: this.config.burst,
|
|
56
|
+
updatedMs: nowMs
|
|
57
|
+
};
|
|
58
|
+
const elapsedSec = (nowMs - bucket.updatedMs) / 1000;
|
|
59
|
+
bucket.tokens = Math.min(this.config.burst, bucket.tokens + elapsedSec * this.config.ratePerSec);
|
|
60
|
+
bucket.updatedMs = nowMs;
|
|
61
|
+
if (bucket.tokens < 1) {
|
|
62
|
+
this.buckets.set(key, bucket);
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
bucket.tokens -= 1;
|
|
66
|
+
this.buckets.set(key, bucket);
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
/** Is the key currently locked out for repeated auth failures? */
|
|
70
|
+
isLockedOut(key) {
|
|
71
|
+
const state = this.failures.get(key);
|
|
72
|
+
return state !== undefined && this.now() < state.lockedUntilMs;
|
|
73
|
+
}
|
|
74
|
+
recordAuthFailure(key) {
|
|
75
|
+
const nowMs = this.now();
|
|
76
|
+
const state = this.failures.get(key) ?? {
|
|
77
|
+
count: 0,
|
|
78
|
+
windowStartMs: nowMs,
|
|
79
|
+
lockedUntilMs: 0
|
|
80
|
+
};
|
|
81
|
+
if (nowMs - state.windowStartMs > this.config.authFailureWindowMs) {
|
|
82
|
+
state.count = 0;
|
|
83
|
+
state.windowStartMs = nowMs;
|
|
84
|
+
}
|
|
85
|
+
state.count += 1;
|
|
86
|
+
if (state.count >= this.config.authFailureLimit) {
|
|
87
|
+
state.lockedUntilMs = nowMs + this.config.authFailureWindowMs;
|
|
88
|
+
}
|
|
89
|
+
this.failures.set(key, state);
|
|
90
|
+
}
|
|
91
|
+
recordAuthSuccess(key) {
|
|
92
|
+
this.failures.delete(key);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type ChainedEvent, type Receipt, type RunContract } from "@fusionkit/protocol";
|
|
2
|
+
export type ReceiptServiceConfig = {
|
|
3
|
+
planePrivateKeyPem: string;
|
|
4
|
+
planePublicKeyPem: string;
|
|
5
|
+
};
|
|
6
|
+
export declare class ReceiptService {
|
|
7
|
+
private readonly config;
|
|
8
|
+
constructor(config: ReceiptServiceConfig);
|
|
9
|
+
verifyRunnerReceipt(input: {
|
|
10
|
+
contract: RunContract;
|
|
11
|
+
receipt: Receipt;
|
|
12
|
+
events: ChainedEvent[];
|
|
13
|
+
runnerPublicKeyPem: string;
|
|
14
|
+
}): void;
|
|
15
|
+
countersign(receipt: Receipt): Receipt;
|
|
16
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { signReceipt, verifyRunnerReceipt } from "@fusionkit/protocol";
|
|
2
|
+
import { badRequest } from "./domain-errors.js";
|
|
3
|
+
export class ReceiptService {
|
|
4
|
+
config;
|
|
5
|
+
constructor(config) {
|
|
6
|
+
this.config = config;
|
|
7
|
+
}
|
|
8
|
+
verifyRunnerReceipt(input) {
|
|
9
|
+
const result = verifyRunnerReceipt(input);
|
|
10
|
+
if (!result.ok) {
|
|
11
|
+
throw badRequest(`receipt runner verification failed: ${result.problems.join("; ")}`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
countersign(receipt) {
|
|
15
|
+
return signReceipt(receipt, this.config.planePrivateKeyPem, this.config.planePublicKeyPem, "plane");
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { RetentionPolicy } from "@fusionkit/protocol";
|
|
2
|
+
import type { Logger } from "./logging.js";
|
|
3
|
+
import type { PlaneStore } from "./store.js";
|
|
4
|
+
/**
|
|
5
|
+
* Compute every blob hash still referenced by a surviving run: workspace
|
|
6
|
+
* manifests, continuation envelopes (and the transcript/journal blobs those
|
|
7
|
+
* envelopes reference), event artifacts, and receipt outputs. Anything not
|
|
8
|
+
* in this set is unreachable and safe to GC.
|
|
9
|
+
*/
|
|
10
|
+
export declare function collectReferencedBlobs(store: PlaneStore): Set<string>;
|
|
11
|
+
export type RetentionResult = {
|
|
12
|
+
deletedRuns: string[];
|
|
13
|
+
deletedBlobs: number;
|
|
14
|
+
prunedNonces: number;
|
|
15
|
+
};
|
|
16
|
+
export declare class RetentionSweeper {
|
|
17
|
+
private readonly store;
|
|
18
|
+
private readonly policy;
|
|
19
|
+
private readonly intervalMs;
|
|
20
|
+
private readonly logger?;
|
|
21
|
+
private timer;
|
|
22
|
+
constructor(store: PlaneStore, policy: RetentionPolicy, intervalMs?: number, logger?: Logger | undefined);
|
|
23
|
+
/**
|
|
24
|
+
* Run one retention pass: expire terminal runs past the retention horizon,
|
|
25
|
+
* GC unreferenced blobs, and prune expired nonces. A run is retained for
|
|
26
|
+
* `receiptsDays`; its artifacts live exactly as long as the run does, so
|
|
27
|
+
* `artifactsDays` is honored as the floor — artifacts never outlive their
|
|
28
|
+
* receipt, which must stay verifiable while the receipt is retained.
|
|
29
|
+
*/
|
|
30
|
+
sweepOnce(now?: number): RetentionResult;
|
|
31
|
+
start(): void;
|
|
32
|
+
stop(): void;
|
|
33
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { TERMINAL_RUN_STATUSES } from "@fusionkit/protocol";
|
|
2
|
+
const TERMINAL = TERMINAL_RUN_STATUSES;
|
|
3
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
4
|
+
/** Default interval between background retention passes (override in ctor). */
|
|
5
|
+
const DEFAULT_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
|
6
|
+
function addManifestHashes(keep, manifest) {
|
|
7
|
+
keep.add(manifest.bundleHash);
|
|
8
|
+
if (manifest.dirtyDiffHash)
|
|
9
|
+
keep.add(manifest.dirtyDiffHash);
|
|
10
|
+
for (const file of manifest.untrackedFiles)
|
|
11
|
+
keep.add(file.hash);
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Compute every blob hash still referenced by a surviving run: workspace
|
|
15
|
+
* manifests, continuation envelopes (and the transcript/journal blobs those
|
|
16
|
+
* envelopes reference), event artifacts, and receipt outputs. Anything not
|
|
17
|
+
* in this set is unreachable and safe to GC.
|
|
18
|
+
*/
|
|
19
|
+
export function collectReferencedBlobs(store) {
|
|
20
|
+
const keep = new Set();
|
|
21
|
+
const envelopeHashes = new Set();
|
|
22
|
+
for (const run of store.listRuns()) {
|
|
23
|
+
addManifestHashes(keep, run.request.workspace);
|
|
24
|
+
if (run.request.continuation) {
|
|
25
|
+
keep.add(run.request.continuation.envelopeHash);
|
|
26
|
+
envelopeHashes.add(run.request.continuation.envelopeHash);
|
|
27
|
+
}
|
|
28
|
+
if (run.contract) {
|
|
29
|
+
addManifestHashes(keep, run.contract.workspace);
|
|
30
|
+
if (run.contract.continuation) {
|
|
31
|
+
keep.add(run.contract.continuation.envelopeHash);
|
|
32
|
+
envelopeHashes.add(run.contract.continuation.envelopeHash);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const receipt = store.getReceipt(run.id);
|
|
36
|
+
if (receipt) {
|
|
37
|
+
if (receipt.workspaceOut.diffHash)
|
|
38
|
+
keep.add(receipt.workspaceOut.diffHash);
|
|
39
|
+
for (const hash of receipt.workspaceOut.artifactHashes)
|
|
40
|
+
keep.add(hash);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for (const { event } of store.exportEvents(0)) {
|
|
44
|
+
if (event.event.type === "artifact.created")
|
|
45
|
+
keep.add(event.event.hash);
|
|
46
|
+
if (event.event.type === "boundary.crossed")
|
|
47
|
+
keep.add(event.event.contentHash);
|
|
48
|
+
}
|
|
49
|
+
// Envelopes reference transcript/journal/workspace blobs from inside their
|
|
50
|
+
// (content-addressed) JSON; parse the surviving ones to keep those too.
|
|
51
|
+
for (const hash of envelopeHashes) {
|
|
52
|
+
const blob = store.getBlob(hash);
|
|
53
|
+
if (!blob)
|
|
54
|
+
continue;
|
|
55
|
+
// The envelope blob is content-addressed (its hash is what we looked it
|
|
56
|
+
// up by), so its bytes are exactly what was sealed at submission time; a
|
|
57
|
+
// parse failure here means a truncated/foreign blob, which we skip
|
|
58
|
+
// rather than letting it abort the whole GC pass.
|
|
59
|
+
let envelope;
|
|
60
|
+
try {
|
|
61
|
+
envelope = JSON.parse(blob.toString("utf8"));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const semantic = envelope.checkpoint.semantic;
|
|
67
|
+
if (semantic?.transcriptHash)
|
|
68
|
+
keep.add(semantic.transcriptHash);
|
|
69
|
+
if (semantic?.toolJournalHash)
|
|
70
|
+
keep.add(semantic.toolJournalHash);
|
|
71
|
+
if (envelope.checkpoint.workspace) {
|
|
72
|
+
addManifestHashes(keep, envelope.checkpoint.workspace);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return keep;
|
|
76
|
+
}
|
|
77
|
+
export class RetentionSweeper {
|
|
78
|
+
store;
|
|
79
|
+
policy;
|
|
80
|
+
intervalMs;
|
|
81
|
+
logger;
|
|
82
|
+
timer;
|
|
83
|
+
constructor(store, policy, intervalMs = DEFAULT_SWEEP_INTERVAL_MS, logger) {
|
|
84
|
+
this.store = store;
|
|
85
|
+
this.policy = policy;
|
|
86
|
+
this.intervalMs = intervalMs;
|
|
87
|
+
this.logger = logger;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Run one retention pass: expire terminal runs past the retention horizon,
|
|
91
|
+
* GC unreferenced blobs, and prune expired nonces. A run is retained for
|
|
92
|
+
* `receiptsDays`; its artifacts live exactly as long as the run does, so
|
|
93
|
+
* `artifactsDays` is honored as the floor — artifacts never outlive their
|
|
94
|
+
* receipt, which must stay verifiable while the receipt is retained.
|
|
95
|
+
*/
|
|
96
|
+
sweepOnce(now = Date.now()) {
|
|
97
|
+
const cutoff = now - this.policy.receiptsDays * DAY_MS;
|
|
98
|
+
const deletedRuns = this.store.deleteRunsUpdatedBefore(cutoff, [...TERMINAL]);
|
|
99
|
+
const keep = collectReferencedBlobs(this.store);
|
|
100
|
+
const deletedBlobs = this.store.deleteBlobsExcept(keep);
|
|
101
|
+
const prunedNonces = this.store.pruneClaimNonces(now);
|
|
102
|
+
return { deletedRuns, deletedBlobs, prunedNonces };
|
|
103
|
+
}
|
|
104
|
+
start() {
|
|
105
|
+
if (this.timer)
|
|
106
|
+
return;
|
|
107
|
+
this.timer = setInterval(() => {
|
|
108
|
+
try {
|
|
109
|
+
this.sweepOnce();
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
// Never let a sweep failure crash the plane; surface it for ops.
|
|
113
|
+
this.logger?.error({ err: error instanceof Error ? error.message : String(error) }, "retention sweep failed");
|
|
114
|
+
}
|
|
115
|
+
}, this.intervalMs);
|
|
116
|
+
this.timer.unref?.();
|
|
117
|
+
}
|
|
118
|
+
stop() {
|
|
119
|
+
if (this.timer)
|
|
120
|
+
clearInterval(this.timer);
|
|
121
|
+
this.timer = undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { conflict } from "./domain-errors.js";
|
|
2
|
+
const ALLOWED_TRANSITIONS = new Map([
|
|
3
|
+
["created", ["claimed", "cancelled"]],
|
|
4
|
+
["awaiting_approval", ["created", "cancelled"]],
|
|
5
|
+
["claimed", ["running", "completed", "failed", "cancelled"]],
|
|
6
|
+
["provisioning", ["running", "failed", "cancelled"]],
|
|
7
|
+
["running", ["completed", "failed", "cancelled"]],
|
|
8
|
+
["completed", []],
|
|
9
|
+
["failed", []],
|
|
10
|
+
["cancelled", []]
|
|
11
|
+
]);
|
|
12
|
+
function canTransitionRunStatus(from, to) {
|
|
13
|
+
return ALLOWED_TRANSITIONS.get(from)?.includes(to) ?? false;
|
|
14
|
+
}
|
|
15
|
+
export function assertRunTransition(from, to) {
|
|
16
|
+
if (!canTransitionRunStatus(from, to)) {
|
|
17
|
+
throw conflict(`cannot transition run from ${from} to ${to}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { MasterKey } from "./keys.js";
|
|
2
|
+
/**
|
|
3
|
+
* Org secret store: a single AES-256-GCM file sealed with the plane master
|
|
4
|
+
* key (scrypt-derived per write). Values exist in plaintext only in memory
|
|
5
|
+
* and in the broker-to-runner release channel; they never appear in
|
|
6
|
+
* contracts, events, or receipts, and the encryption key is not derivable
|
|
7
|
+
* from config.json alone.
|
|
8
|
+
*/
|
|
9
|
+
export declare class SecretStore {
|
|
10
|
+
private readonly path;
|
|
11
|
+
private readonly master;
|
|
12
|
+
private cache?;
|
|
13
|
+
constructor(path: string, master: MasterKey);
|
|
14
|
+
private load;
|
|
15
|
+
private save;
|
|
16
|
+
set(name: string, value: string): void;
|
|
17
|
+
/** Rotate a secret's value, preserving its name; errors if absent. */
|
|
18
|
+
rotate(name: string, value: string): void;
|
|
19
|
+
remove(name: string): boolean;
|
|
20
|
+
names(): string[];
|
|
21
|
+
release(names: string[]): {
|
|
22
|
+
name: string;
|
|
23
|
+
value: string;
|
|
24
|
+
}[];
|
|
25
|
+
}
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { open, seal } from "./keys.js";
|
|
3
|
+
/**
|
|
4
|
+
* Org secret store: a single AES-256-GCM file sealed with the plane master
|
|
5
|
+
* key (scrypt-derived per write). Values exist in plaintext only in memory
|
|
6
|
+
* and in the broker-to-runner release channel; they never appear in
|
|
7
|
+
* contracts, events, or receipts, and the encryption key is not derivable
|
|
8
|
+
* from config.json alone.
|
|
9
|
+
*/
|
|
10
|
+
export class SecretStore {
|
|
11
|
+
path;
|
|
12
|
+
master;
|
|
13
|
+
// Decrypt-once cache invalidated by file mtime, so a concurrent writer's
|
|
14
|
+
// update is still picked up. Single-process optimization; cross-process
|
|
15
|
+
// coordination is the store's job, not the secret file's.
|
|
16
|
+
cache;
|
|
17
|
+
constructor(path, master) {
|
|
18
|
+
this.path = path;
|
|
19
|
+
this.master = master;
|
|
20
|
+
}
|
|
21
|
+
load() {
|
|
22
|
+
if (!existsSync(this.path))
|
|
23
|
+
return {};
|
|
24
|
+
const mtimeMs = statSync(this.path).mtimeMs;
|
|
25
|
+
if (this.cache && this.cache.mtimeMs === mtimeMs)
|
|
26
|
+
return this.cache.values;
|
|
27
|
+
const blob = JSON.parse(readFileSync(this.path, "utf8"));
|
|
28
|
+
const plaintext = open(this.master, blob).toString("utf8");
|
|
29
|
+
const values = JSON.parse(plaintext);
|
|
30
|
+
this.cache = { mtimeMs, values };
|
|
31
|
+
return values;
|
|
32
|
+
}
|
|
33
|
+
save(values) {
|
|
34
|
+
const blob = seal(this.master, Buffer.from(JSON.stringify(values), "utf8"));
|
|
35
|
+
writeFileSync(this.path, JSON.stringify(blob), { mode: 0o600 });
|
|
36
|
+
this.cache = { mtimeMs: statSync(this.path).mtimeMs, values };
|
|
37
|
+
}
|
|
38
|
+
set(name, value) {
|
|
39
|
+
const values = this.load();
|
|
40
|
+
values[name] = { value, updatedAt: new Date().toISOString() };
|
|
41
|
+
this.save(values);
|
|
42
|
+
}
|
|
43
|
+
/** Rotate a secret's value, preserving its name; errors if absent. */
|
|
44
|
+
rotate(name, value) {
|
|
45
|
+
const values = this.load();
|
|
46
|
+
if (values[name] === undefined) {
|
|
47
|
+
throw new Error(`secret "${name}" is not in the store`);
|
|
48
|
+
}
|
|
49
|
+
values[name] = { value, updatedAt: new Date().toISOString() };
|
|
50
|
+
this.save(values);
|
|
51
|
+
}
|
|
52
|
+
remove(name) {
|
|
53
|
+
const values = this.load();
|
|
54
|
+
if (values[name] === undefined)
|
|
55
|
+
return false;
|
|
56
|
+
delete values[name];
|
|
57
|
+
this.save(values);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
names() {
|
|
61
|
+
return Object.keys(this.load()).sort();
|
|
62
|
+
}
|
|
63
|
+
release(names) {
|
|
64
|
+
const values = this.load();
|
|
65
|
+
return names.map((name) => {
|
|
66
|
+
const entry = values[name];
|
|
67
|
+
if (entry === undefined) {
|
|
68
|
+
throw new Error(`secret "${name}" is not in the store`);
|
|
69
|
+
}
|
|
70
|
+
return { name, value: entry.value };
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Server } from "node:http";
|
|
2
|
+
import { Plane } from "./plane.js";
|
|
3
|
+
import type { RateLimitConfig } from "./ratelimit.js";
|
|
4
|
+
/** Default request body cap (workspace bundles can be large). */
|
|
5
|
+
export declare const DEFAULT_MAX_BODY_BYTES: number;
|
|
6
|
+
export type PlaneServerOptions = {
|
|
7
|
+
port: number;
|
|
8
|
+
/**
|
|
9
|
+
* Bind host. Defaults to loopback ("127.0.0.1") as a secure-by-default
|
|
10
|
+
* choice; container/K8s deployments pass "0.0.0.0" explicitly (the CLI
|
|
11
|
+
* and docker-compose already do).
|
|
12
|
+
*/
|
|
13
|
+
host?: string;
|
|
14
|
+
rateLimit?: Partial<RateLimitConfig>;
|
|
15
|
+
/** Request body cap in bytes. Defaults to DEFAULT_MAX_BODY_BYTES. */
|
|
16
|
+
maxBodyBytes?: number;
|
|
17
|
+
/**
|
|
18
|
+
* HTTP keep-alive idle timeout in ms. Defaults to 0 (disabled): clients
|
|
19
|
+
* in this repo retry idempotent requests, and disabling the idle timer
|
|
20
|
+
* avoids closed-socket races. Set a positive value when fronted by a
|
|
21
|
+
* reverse proxy whose own idle timeout should win.
|
|
22
|
+
*/
|
|
23
|
+
keepAliveTimeoutMs?: number;
|
|
24
|
+
/** Trust X-Forwarded-For from a fronting proxy for rate-limit keys. */
|
|
25
|
+
trustProxy?: boolean;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Control-plane HTTP API plus the control panel UI. Every mutating and
|
|
29
|
+
* data-returning route is authenticated against a principal and gated by
|
|
30
|
+
* capability; bodies are schema-validated; requests are rate-limited per
|
|
31
|
+
* principal/IP with auth-failure backoff; everything is logged with a
|
|
32
|
+
* request id.
|
|
33
|
+
*/
|
|
34
|
+
export declare function startPlaneServer(plane: Plane, options: PlaneServerOptions | number): Promise<{
|
|
35
|
+
server: Server;
|
|
36
|
+
port: number;
|
|
37
|
+
host: string;
|
|
38
|
+
}>;
|