@a5c-ai/krate 5.0.1-staging.660d2b90f → 5.0.1-staging.69cb593ea
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/Dockerfile +31 -29
- package/bin/krate-demo.mjs +0 -0
- package/bin/krate-server.mjs +0 -0
- package/dist/krate-controller-ui.json +808 -10
- package/dist/krate-lifecycle.json +1 -1
- package/dist/krate-runtime-snapshot.json +223 -53
- package/dist/krate-summary.json +40 -3
- package/docs/agents/gaps-agent-mux-to-krate-crds.md +298 -0
- package/docs/architecture-v2.md +431 -0
- package/docs/openapi.yaml +1275 -0
- package/docs/requirements-v2.md +238 -0
- package/docs/sdk-api-reference.md +782 -0
- package/docs/system-spec-v2.md +352 -0
- package/docs/todos.md +4 -0
- package/docs/web-console-spec.md +433 -0
- package/package.json +1 -1
- package/scripts/validate-ui.mjs +305 -207
- package/src/agent-adapter-controller.js +169 -0
- package/src/agent-approval-controller.js +47 -0
- package/src/agent-dispatch-controller.js +130 -7
- package/src/agent-gateway-config-controller.js +147 -0
- package/src/agent-memory-controller.js +357 -0
- package/src/agent-memory-import.js +327 -0
- package/src/agent-memory-query.js +292 -0
- package/src/agent-memory-repository-source-controller.js +255 -0
- package/src/agent-mux-client.js +1 -1
- package/src/agent-permission-review.js +102 -14
- package/src/agent-project-controller.js +117 -0
- package/src/agent-provider-config-controller.js +150 -0
- package/src/agent-secret-config-grant-controller.js +282 -0
- package/src/agent-session-transcript-controller.js +189 -0
- package/src/agent-stack-controller.js +52 -1
- package/src/agent-subagent-controller.js +160 -0
- package/src/agent-transport-binding-controller.js +121 -0
- package/src/agent-trigger-controller.js +273 -0
- package/src/agent-workspace-controller.js +702 -0
- package/src/agent-writeback-controller.js +302 -0
- package/src/api-controller.js +338 -3
- package/src/async-controller.js +207 -0
- package/src/audit-controller.js +191 -0
- package/src/auth.js +48 -6
- package/src/controller-client.js +112 -38
- package/src/controller-ui.js +96 -16
- package/src/data-plane.js +3 -2
- package/src/event-bus.js +61 -0
- package/src/external/conflict-controller.js +225 -0
- package/src/external/github/auth.js +96 -0
- package/src/external/github/cicd.js +180 -0
- package/src/external/github/git-forge.js +240 -0
- package/src/external/github/index.js +144 -0
- package/src/external/github/issue-tracking.js +163 -0
- package/src/external/provider-adapter.js +161 -0
- package/src/external/provider-resource-factory.js +161 -0
- package/src/external/sync-controller.js +235 -0
- package/src/external/webhook-controller.js +144 -0
- package/src/external/write-controller.js +283 -0
- package/src/gitea-backend.js +36 -0
- package/src/gitea-service.js +173 -0
- package/src/http-server.js +226 -0
- package/src/index.js +27 -0
- package/src/kubernetes-controller-async.js +531 -0
- package/src/kubernetes-controller.js +156 -84
- package/src/notification-controller.js +178 -0
- package/src/org-scoping.js +5 -0
- package/src/resource-model.js +26 -8
- package/src/runner-controller.js +272 -0
- package/src/snapshot-cache.js +157 -0
- package/tests/agent-adapter-controller.test.js +361 -0
- package/tests/agent-dispatch-controller.test.js +139 -0
- package/tests/agent-gateway-config-controller.test.js +386 -0
- package/tests/agent-memory-controller.test.js +308 -0
- package/tests/agent-memory-import-snapshot.test.js +477 -0
- package/tests/agent-memory-query.test.js +404 -0
- package/tests/agent-memory-repository-source.test.js +514 -0
- package/tests/agent-permission-review-v2.test.js +317 -0
- package/tests/agent-project-controller.test.js +302 -0
- package/tests/agent-provider-config-controller.test.js +376 -0
- package/tests/agent-resources.test.js +35 -19
- package/tests/agent-secret-config-grant.test.js +231 -0
- package/tests/agent-session-transcript-controller.test.js +499 -0
- package/tests/agent-subagent-controller.test.js +201 -0
- package/tests/agent-transport-binding-controller.test.js +294 -0
- package/tests/agent-trigger-routes.test.js +190 -0
- package/tests/agent-trigger-sources.test.js +245 -0
- package/tests/agent-workspace-controller.test.js +181 -0
- package/tests/agent-writeback.test.js +292 -0
- package/tests/approval-persistence.test.js +171 -0
- package/tests/async-controller.test.js +252 -0
- package/tests/audit-controller.test.js +227 -0
- package/tests/codespace-controller.test.js +318 -0
- package/tests/controller-client.test.js +133 -0
- package/tests/deployment.test.js +43 -29
- package/tests/e2e/lifecycle.test.js +5 -2
- package/tests/event-bus-integration.test.js +190 -0
- package/tests/external-github-forge.test.js +560 -0
- package/tests/external-github-issues-cicd.test.js +520 -0
- package/tests/external-integration.test.js +470 -0
- package/tests/external-persistence.test.js +340 -0
- package/tests/external-provider-adapter.test.js +365 -0
- package/tests/external-resource-model.test.js +215 -0
- package/tests/external-webhook-sync.test.js +287 -0
- package/tests/external-write-conflict.test.js +353 -0
- package/tests/gitea-service.test.js +253 -0
- package/tests/health-check-real.test.js +165 -0
- package/tests/integration/full-flow.test.js +266 -0
- package/tests/krate.test.js +58 -6
- package/tests/memory-search-wiring.test.js +270 -0
- package/tests/notification-controller.test.js +196 -0
- package/tests/notification-integration.test.js +179 -0
- package/tests/org-scoping.test.js +687 -0
- package/tests/runner-controller.test.js +327 -0
- package/tests/runner-integration.test.js +231 -0
- package/tests/session-cookie-hmac.test.js +151 -0
- package/tests/snapshot-performance.test.js +315 -0
- package/tests/sse-events.test.js +107 -0
- package/tests/webhook-trigger.test.js +198 -0
- package/tests/workspace-volumes.test.js +312 -0
- package/tests/writeback-persistence.test.js +207 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Async controller utilities for event batching, retry policies, delivery queues,
|
|
3
|
+
* and checkpoint persistence.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Event batcher
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Accumulates events and flushes them in batches either when the batch is full
|
|
12
|
+
* or when the flush interval expires.
|
|
13
|
+
*
|
|
14
|
+
* @param {(events: any[]) => void | Promise<void>} handler - Called with each flushed batch.
|
|
15
|
+
* @param {{ maxBatchSize?: number, flushIntervalMs?: number }} [options]
|
|
16
|
+
* @returns {{ push(event: any): void, flush(): Promise<void>, stop(): void }}
|
|
17
|
+
*/
|
|
18
|
+
export function createEventBatcher(handler, { maxBatchSize = 50, flushIntervalMs = 1000 } = {}) {
|
|
19
|
+
let batch = [];
|
|
20
|
+
let timer = null;
|
|
21
|
+
|
|
22
|
+
function scheduleFlush() {
|
|
23
|
+
if (timer !== null) return;
|
|
24
|
+
timer = setTimeout(async () => {
|
|
25
|
+
timer = null;
|
|
26
|
+
await flushNow();
|
|
27
|
+
}, flushIntervalMs);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function flushNow() {
|
|
31
|
+
if (batch.length === 0) return;
|
|
32
|
+
const toFlush = batch;
|
|
33
|
+
batch = [];
|
|
34
|
+
await handler(toFlush);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
push(event) {
|
|
39
|
+
batch.push(event);
|
|
40
|
+
if (batch.length >= maxBatchSize) {
|
|
41
|
+
if (timer !== null) {
|
|
42
|
+
clearTimeout(timer);
|
|
43
|
+
timer = null;
|
|
44
|
+
}
|
|
45
|
+
// Fire-and-forget the synchronous portion; handler may return a Promise
|
|
46
|
+
const toFlush = batch;
|
|
47
|
+
batch = [];
|
|
48
|
+
Promise.resolve(handler(toFlush)).catch(() => {});
|
|
49
|
+
} else {
|
|
50
|
+
scheduleFlush();
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
async flush() {
|
|
54
|
+
if (timer !== null) {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
timer = null;
|
|
57
|
+
}
|
|
58
|
+
await flushNow();
|
|
59
|
+
},
|
|
60
|
+
stop() {
|
|
61
|
+
if (timer !== null) {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
timer = null;
|
|
64
|
+
}
|
|
65
|
+
batch = [];
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Retry policy
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Creates a retry policy with exponential backoff and optional jitter.
|
|
76
|
+
*
|
|
77
|
+
* @param {{ maxRetries?: number, baseDelayMs?: number, maxDelayMs?: number, jitter?: boolean }} [options]
|
|
78
|
+
* @returns {{ shouldRetry(attempt: number, error: any): boolean, getDelay(attempt: number): number }}
|
|
79
|
+
*/
|
|
80
|
+
export function createRetryPolicy({ maxRetries = 3, baseDelayMs = 1000, maxDelayMs = 30000, jitter = true } = {}) {
|
|
81
|
+
return {
|
|
82
|
+
/**
|
|
83
|
+
* Returns true if another attempt should be made.
|
|
84
|
+
* @param {number} attempt - 0-based number of the attempt that just failed.
|
|
85
|
+
*/
|
|
86
|
+
shouldRetry(attempt, _error) {
|
|
87
|
+
return attempt < maxRetries;
|
|
88
|
+
},
|
|
89
|
+
/**
|
|
90
|
+
* Returns the delay in ms to wait before the next attempt.
|
|
91
|
+
* @param {number} attempt - 0-based number of the attempt that just failed.
|
|
92
|
+
*/
|
|
93
|
+
getDelay(attempt) {
|
|
94
|
+
const exponential = baseDelayMs * Math.pow(2, attempt);
|
|
95
|
+
const capped = Math.min(exponential, maxDelayMs);
|
|
96
|
+
if (!jitter) return capped;
|
|
97
|
+
// Full-jitter: random value in [0, capped]
|
|
98
|
+
return Math.floor(Math.random() * (capped + 1));
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Delivery queue
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* In-memory ordered queue with configurable concurrency and optional retry support.
|
|
109
|
+
*
|
|
110
|
+
* @param {(item: any) => Promise<void>} processor - Called for each dequeued item.
|
|
111
|
+
* @param {{ concurrency?: number, retryPolicy?: ReturnType<typeof createRetryPolicy> }} [options]
|
|
112
|
+
* @returns {{ enqueue(item: any): void, drain(): Promise<void>, size(): number, stop(): void }}
|
|
113
|
+
*/
|
|
114
|
+
export function createDeliveryQueue(processor, { concurrency = 5, retryPolicy } = {}) {
|
|
115
|
+
const queue = [];
|
|
116
|
+
let active = 0;
|
|
117
|
+
let stopped = false;
|
|
118
|
+
/** @type {Array<() => void>} */
|
|
119
|
+
let drainResolvers = [];
|
|
120
|
+
|
|
121
|
+
function checkDrain() {
|
|
122
|
+
if (active === 0 && queue.length === 0) {
|
|
123
|
+
for (const resolve of drainResolvers) resolve();
|
|
124
|
+
drainResolvers = [];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function processItem(item) {
|
|
129
|
+
let attempt = 0;
|
|
130
|
+
while (true) {
|
|
131
|
+
try {
|
|
132
|
+
await processor(item);
|
|
133
|
+
return;
|
|
134
|
+
} catch (err) {
|
|
135
|
+
if (retryPolicy && retryPolicy.shouldRetry(attempt, err)) {
|
|
136
|
+
const delay = retryPolicy.getDelay(attempt);
|
|
137
|
+
attempt++;
|
|
138
|
+
if (delay > 0) await new Promise((r) => setTimeout(r, delay));
|
|
139
|
+
} else {
|
|
140
|
+
// Swallow the error; callers can handle via processor rejections externally
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function tick() {
|
|
148
|
+
while (!stopped && queue.length > 0 && active < concurrency) {
|
|
149
|
+
const item = queue.shift();
|
|
150
|
+
active++;
|
|
151
|
+
processItem(item).finally(() => {
|
|
152
|
+
active--;
|
|
153
|
+
tick();
|
|
154
|
+
checkDrain();
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
if (!stopped) checkDrain();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
enqueue(item) {
|
|
162
|
+
if (stopped) return;
|
|
163
|
+
queue.push(item);
|
|
164
|
+
tick();
|
|
165
|
+
},
|
|
166
|
+
drain() {
|
|
167
|
+
if (active === 0 && queue.length === 0) return Promise.resolve();
|
|
168
|
+
return new Promise((resolve) => drainResolvers.push(resolve));
|
|
169
|
+
},
|
|
170
|
+
size() {
|
|
171
|
+
return queue.length + active;
|
|
172
|
+
},
|
|
173
|
+
stop() {
|
|
174
|
+
stopped = true;
|
|
175
|
+
queue.length = 0;
|
|
176
|
+
for (const resolve of drainResolvers) resolve();
|
|
177
|
+
drainResolvers = [];
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// Checkpointer
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Simple key-value checkpoint persistence backed by any Map-like storage.
|
|
188
|
+
*
|
|
189
|
+
* @param {Map<string, any>} [storage]
|
|
190
|
+
* @returns {{ save(key: string, value: any): void, load(key: string): any, clear(key: string): void, listKeys(): string[] }}
|
|
191
|
+
*/
|
|
192
|
+
export function createCheckpointer(storage = new Map()) {
|
|
193
|
+
return {
|
|
194
|
+
save(key, value) {
|
|
195
|
+
storage.set(key, value);
|
|
196
|
+
},
|
|
197
|
+
load(key) {
|
|
198
|
+
return storage.has(key) ? storage.get(key) : undefined;
|
|
199
|
+
},
|
|
200
|
+
clear(key) {
|
|
201
|
+
storage.delete(key);
|
|
202
|
+
},
|
|
203
|
+
listKeys() {
|
|
204
|
+
return Array.from(storage.keys());
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Audit Controller — Org-scoped audit log, event streaming, smart polling with
|
|
3
|
+
* exponential backoff, replay on reconnect, and metrics aggregation.
|
|
4
|
+
*
|
|
5
|
+
* @module audit-controller
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const AUDIT_CONTROLLER_BOUNDARY = {
|
|
9
|
+
role: 'audit-controller',
|
|
10
|
+
scope: 'Org-scoped audit log — event recording, streaming, replay, metrics',
|
|
11
|
+
owns: ['audit events', 'event streaming', 'event polling', 'audit metrics'],
|
|
12
|
+
delegatesTo: [],
|
|
13
|
+
mustNotOwn: ['identity management', 'resource storage', 'git operations'],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// ─── AuditController ─────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create an in-memory audit controller.
|
|
20
|
+
*
|
|
21
|
+
* @returns {{
|
|
22
|
+
* log: Function,
|
|
23
|
+
* query: Function,
|
|
24
|
+
* getStream: Function,
|
|
25
|
+
* getMetrics: Function,
|
|
26
|
+
* }}
|
|
27
|
+
*/
|
|
28
|
+
export function createAuditController() {
|
|
29
|
+
/** @type {Array<AuditEvent>} */
|
|
30
|
+
const store = [];
|
|
31
|
+
let seq = 0;
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
role: 'audit-controller',
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Record an audit event.
|
|
38
|
+
*
|
|
39
|
+
* @param {{ org: string, actor?: string, action: string, resource?: object, timestamp?: string }} params
|
|
40
|
+
* @returns {AuditEvent}
|
|
41
|
+
*/
|
|
42
|
+
log({ org, actor = 'system', action, resource = {}, timestamp } = {}) {
|
|
43
|
+
if (!org || typeof org !== 'string') {
|
|
44
|
+
throw new Error('audit.log: org is required');
|
|
45
|
+
}
|
|
46
|
+
if (!action || typeof action !== 'string') {
|
|
47
|
+
throw new Error('audit.log: action is required');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const event = {
|
|
51
|
+
id: ++seq,
|
|
52
|
+
org,
|
|
53
|
+
actor,
|
|
54
|
+
action,
|
|
55
|
+
resource: Object.assign({}, resource),
|
|
56
|
+
timestamp: timestamp || new Date().toISOString(),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
store.push(event);
|
|
60
|
+
return Object.assign({}, event);
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Query audit events with filtering and pagination.
|
|
65
|
+
*
|
|
66
|
+
* @param {{ org?: string, action?: string, since?: string, until?: string, limit?: number, offset?: number }} params
|
|
67
|
+
* @returns {{ events: AuditEvent[], total: number }}
|
|
68
|
+
*/
|
|
69
|
+
query({ org, action, since, until, limit, offset = 0 } = {}) {
|
|
70
|
+
let filtered = store.slice();
|
|
71
|
+
|
|
72
|
+
if (org) filtered = filtered.filter(e => e.org === org);
|
|
73
|
+
if (action) filtered = filtered.filter(e => e.action === action);
|
|
74
|
+
|
|
75
|
+
if (since) {
|
|
76
|
+
const sinceMs = new Date(since).getTime();
|
|
77
|
+
filtered = filtered.filter(e => new Date(e.timestamp).getTime() >= sinceMs);
|
|
78
|
+
}
|
|
79
|
+
if (until) {
|
|
80
|
+
const untilMs = new Date(until).getTime();
|
|
81
|
+
filtered = filtered.filter(e => new Date(e.timestamp).getTime() <= untilMs);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// reverse chronological
|
|
85
|
+
filtered = filtered.slice().sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
|
86
|
+
|
|
87
|
+
const total = filtered.length;
|
|
88
|
+
filtered = filtered.slice(offset);
|
|
89
|
+
if (limit != null) filtered = filtered.slice(0, limit);
|
|
90
|
+
|
|
91
|
+
return { events: filtered.map(e => Object.assign({}, e)), total };
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Return all events after the given sequence number for a given org (event replay).
|
|
96
|
+
*
|
|
97
|
+
* @param {{ org?: string, afterSeq: number }} params
|
|
98
|
+
* @returns {{ events: AuditEvent[], lastSeq: number }}
|
|
99
|
+
*/
|
|
100
|
+
getStream({ org, afterSeq = 0 } = {}) {
|
|
101
|
+
let filtered = store.filter(e => e.id > afterSeq);
|
|
102
|
+
if (org) filtered = filtered.filter(e => e.org === org);
|
|
103
|
+
// chronological order for stream replay
|
|
104
|
+
filtered = filtered.slice().sort((a, b) => a.id - b.id);
|
|
105
|
+
return {
|
|
106
|
+
events: filtered.map(e => Object.assign({}, e)),
|
|
107
|
+
lastSeq: filtered.length > 0 ? filtered[filtered.length - 1].id : afterSeq,
|
|
108
|
+
};
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Aggregate audit metrics for an org.
|
|
113
|
+
*
|
|
114
|
+
* @param {{ org?: string }} params
|
|
115
|
+
* @returns {{ byAction: object, byOrg: object, byHour: object, total: number }}
|
|
116
|
+
*/
|
|
117
|
+
getMetrics({ org } = {}) {
|
|
118
|
+
let events = store.slice();
|
|
119
|
+
if (org) events = events.filter(e => e.org === org);
|
|
120
|
+
|
|
121
|
+
const byAction = {};
|
|
122
|
+
const byOrg = {};
|
|
123
|
+
const byHour = {};
|
|
124
|
+
|
|
125
|
+
for (const event of events) {
|
|
126
|
+
byAction[event.action] = (byAction[event.action] || 0) + 1;
|
|
127
|
+
byOrg[event.org] = (byOrg[event.org] || 0) + 1;
|
|
128
|
+
// hour key: "2026-05-13T10" (drop minutes/seconds)
|
|
129
|
+
const hourKey = event.timestamp.slice(0, 13);
|
|
130
|
+
byHour[hourKey] = (byHour[hourKey] || 0) + 1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { byAction, byOrg, byHour, total: events.length };
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ─── EventPoller ─────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Create a smart event poller with exponential backoff.
|
|
142
|
+
*
|
|
143
|
+
* When polls return no new events the backoff interval doubles (up to maxBackoff).
|
|
144
|
+
* When new events arrive the backoff resets to initialBackoff.
|
|
145
|
+
*
|
|
146
|
+
* @param {{ controller: object, org?: string, initialBackoff?: number, maxBackoff?: number }} options
|
|
147
|
+
* @returns {{ poll: Function, getBackoff: Function, reset: Function }}
|
|
148
|
+
*/
|
|
149
|
+
export function createEventPoller({ controller, org, initialBackoff = 1000, maxBackoff = 30000 } = {}) {
|
|
150
|
+
let lastSeq = 0;
|
|
151
|
+
let currentBackoff = initialBackoff;
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
/**
|
|
155
|
+
* Poll for new events. Updates backoff state.
|
|
156
|
+
* @returns {{ events: AuditEvent[], lastSeq: number }}
|
|
157
|
+
*/
|
|
158
|
+
poll() {
|
|
159
|
+
const result = controller.getStream({ org, afterSeq: lastSeq });
|
|
160
|
+
if (result.events.length > 0) {
|
|
161
|
+
// New events — reset backoff and advance cursor
|
|
162
|
+
lastSeq = result.lastSeq;
|
|
163
|
+
currentBackoff = initialBackoff;
|
|
164
|
+
} else {
|
|
165
|
+
// No new events — double the backoff, capped at maxBackoff
|
|
166
|
+
currentBackoff = Math.min(currentBackoff * 2, maxBackoff);
|
|
167
|
+
}
|
|
168
|
+
return result;
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Get the current backoff interval in milliseconds.
|
|
173
|
+
* @returns {number}
|
|
174
|
+
*/
|
|
175
|
+
getBackoff() {
|
|
176
|
+
return currentBackoff;
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Reset the poller cursor and backoff to their initial states.
|
|
181
|
+
*/
|
|
182
|
+
reset() {
|
|
183
|
+
lastSeq = 0;
|
|
184
|
+
currentBackoff = initialBackoff;
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* @typedef {{ id: number, org: string, actor: string, action: string, resource: object, timestamp: string }} AuditEvent
|
|
191
|
+
*/
|
package/src/auth.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
1
2
|
import { createResource, clone } from './resource-model.js';
|
|
2
3
|
import { mapOidcIdentity } from './identity-policy.js';
|
|
3
4
|
|
|
@@ -136,22 +137,63 @@ export function profileFromDelegatedHeaders(headers, config = createAuthProvider
|
|
|
136
137
|
return { provider: 'delegated', subject: user, email, displayName: user, username: normalizeName(user), groups, teams: [], admin: groups.includes('krate:platform-engineers') || groups.includes('krate:repo-admins'), delegatedIdentitySource: headerUser ? 'proxy-header' : 'local-development' };
|
|
137
138
|
}
|
|
138
139
|
|
|
139
|
-
export async function registerLoginProfile({ controller, namespace
|
|
140
|
-
const
|
|
140
|
+
export async function registerLoginProfile({ controller, namespace, profile }) {
|
|
141
|
+
const org = process.env.KRATE_ADMIN_ORG || process.env.KRATE_ORG || 'default';
|
|
142
|
+
const orgNamespace = namespace || `krate-org-${org}`;
|
|
143
|
+
const adminUsername = process.env.KRATE_ADMIN_USERNAME || '';
|
|
144
|
+
const isBootstrapAdmin = adminUsername && (profile.username === adminUsername || profile.email === adminUsername || normalizeName(profile.email || '') === adminUsername || normalizeName(profile.username || '') === adminUsername);
|
|
145
|
+
const mapped = mapLoginProfileToKrateIdentity({ ...profile, namespace: orgNamespace, organizationRef: org, admin: isBootstrapAdmin || profile.admin });
|
|
141
146
|
const userResult = await controller.applyResource(mapped.user);
|
|
142
147
|
const mappingResult = await controller.applyResource(mapped.mapping);
|
|
143
148
|
return { ...mapped, userResult, mappingResult };
|
|
144
149
|
}
|
|
145
150
|
|
|
146
|
-
export function createSessionCookie(config, profile) {
|
|
147
|
-
const
|
|
151
|
+
export function createSessionCookie(config, profile, options = {}) {
|
|
152
|
+
const secret = options.secret || process.env.KRATE_SESSION_SECRET || '';
|
|
153
|
+
const payload = Buffer.from(JSON.stringify({ provider: profile.provider, subject: profile.subject, user: profile.username || profile.email })).toString('base64url');
|
|
154
|
+
let value;
|
|
155
|
+
if (secret) {
|
|
156
|
+
const signature = createHmac('sha256', secret).update(payload).digest('base64url');
|
|
157
|
+
value = `${payload}.${signature}`;
|
|
158
|
+
} else {
|
|
159
|
+
value = payload;
|
|
160
|
+
}
|
|
148
161
|
return `${config.session.cookieName}=${value}; Path=/; HttpOnly; SameSite=Lax`;
|
|
149
162
|
}
|
|
150
163
|
|
|
151
|
-
export function parseSessionCookie(config, cookieValue) {
|
|
164
|
+
export function parseSessionCookie(config, cookieValue, options = {}) {
|
|
152
165
|
if (!cookieValue || typeof cookieValue !== 'string') return null;
|
|
166
|
+
const secret = options.secret || process.env.KRATE_SESSION_SECRET || '';
|
|
153
167
|
try {
|
|
154
|
-
const
|
|
168
|
+
const dotIndex = cookieValue.indexOf('.');
|
|
169
|
+
const isSigned = dotIndex !== -1;
|
|
170
|
+
|
|
171
|
+
if (isSigned && !secret) {
|
|
172
|
+
// Signed cookie but no secret to verify — reject
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (!isSigned && secret) {
|
|
177
|
+
// No signature present but secret is configured — reject (could be tampered or unsigned legacy)
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
let payload;
|
|
182
|
+
if (isSigned && secret) {
|
|
183
|
+
payload = cookieValue.slice(0, dotIndex);
|
|
184
|
+
const receivedSig = cookieValue.slice(dotIndex + 1);
|
|
185
|
+
const expectedSig = createHmac('sha256', secret).update(payload).digest('base64url');
|
|
186
|
+
// Constant-time comparison
|
|
187
|
+
const expected = Buffer.from(expectedSig, 'base64url');
|
|
188
|
+
const received = Buffer.from(receivedSig, 'base64url');
|
|
189
|
+
if (expected.length !== received.length || !timingSafeEqual(expected, received)) {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
payload = cookieValue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const session = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
155
197
|
const user = typeof session.user === 'string' ? session.user.trim() : '';
|
|
156
198
|
const subject = typeof session.subject === 'string' ? session.subject.trim() : '';
|
|
157
199
|
const provider = typeof session.provider === 'string' ? session.provider.trim() : '';
|
package/src/controller-client.js
CHANGED
|
@@ -1,38 +1,112 @@
|
|
|
1
|
-
import { createControllerUiModel } from './controller-ui.js';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
1
|
+
import { createControllerUiModel } from './controller-ui.js';
|
|
2
|
+
import { clearSnapshotCache, staleWhileRevalidate } from './snapshot-cache.js';
|
|
3
|
+
import { getControllerSnapshotAsync } from './kubernetes-controller-async.js';
|
|
4
|
+
|
|
5
|
+
export { clearSnapshotCache };
|
|
6
|
+
|
|
7
|
+
const CONTROLLER_REQUEST_TIMEOUT_MS = Number(process.env.KRATE_CONTROLLER_REQUEST_TIMEOUT_MS || 5_000);
|
|
8
|
+
|
|
9
|
+
export async function fetchControllerUiModel({ controllerUrl = process.env.KRATE_CONTROLLER_URL, fetchImpl = globalThis.fetch, controller = null, organization = process.env.KRATE_ORG || null, localFallback = true, requestTimeoutMs = CONTROLLER_REQUEST_TIMEOUT_MS, useCache = true, swrOptions = {}, fallbackSnapshot = getControllerSnapshotAsync } = {}) {
|
|
10
|
+
const revalidateFn = async () => {
|
|
11
|
+
if (controllerUrl) {
|
|
12
|
+
try {
|
|
13
|
+
const target = new URL('/api/controller', controllerUrl);
|
|
14
|
+
if (organization) target.searchParams.set('org', organization);
|
|
15
|
+
const signal = requestTimeoutMs > 0 && globalThis.AbortSignal?.timeout ? AbortSignal.timeout(requestTimeoutMs) : undefined;
|
|
16
|
+
const response = await fetchImpl(target, { cache: 'no-store', ...(signal ? { signal } : {}) });
|
|
17
|
+
if (!response.ok) throw new Error(`controller API ${response.status}`);
|
|
18
|
+
const remoteModel = await response.json();
|
|
19
|
+
if (localFallback && shouldFallbackFromRemoteModel(remoteModel)) {
|
|
20
|
+
return fallbackControllerModel({ controller, connectionError: new Error(remoteControllerError(remoteModel) || 'controller returned degraded empty data'), organization, fallbackSnapshot });
|
|
21
|
+
}
|
|
22
|
+
if (localFallback && shouldProbeLocalModel(remoteModel)) {
|
|
23
|
+
const localModel = await fallbackControllerModel({ controller, organization, fallbackSnapshot });
|
|
24
|
+
if (modelResourceScore(localModel) > modelResourceScore(remoteModel)) return localModel;
|
|
25
|
+
}
|
|
26
|
+
return remoteModel;
|
|
27
|
+
} catch (error) {
|
|
28
|
+
if (localFallback) return fallbackControllerModel({ controller, connectionError: error, organization, fallbackSnapshot });
|
|
29
|
+
return unavailableControllerModel(error.message, organization);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (!localFallback) return unavailableControllerModel('KRATE_CONTROLLER_URL is not configured', organization);
|
|
33
|
+
return fallbackControllerModel({ controller, organization, fallbackSnapshot });
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
if (!useCache) return revalidateFn();
|
|
37
|
+
return staleWhileRevalidate(organization, revalidateFn, swrOptions);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function fallbackControllerModel({ controller = null, connectionError = null, organization = null, fallbackSnapshot = getControllerSnapshotAsync } = {}) {
|
|
41
|
+
try {
|
|
42
|
+
const snapshot = controller ? await controller.snapshot() : await fallbackSnapshot();
|
|
43
|
+
const model = createControllerUiModel(snapshot, { organization });
|
|
44
|
+
if (connectionError) model.controller.connection.errors = [connectionError.message, ...(model.controller.connection.errors || [])];
|
|
45
|
+
return model;
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return createControllerUiModel({
|
|
48
|
+
source: 'kubernetes',
|
|
49
|
+
namespace: process.env.KRATE_NAMESPACE || 'krate-system',
|
|
50
|
+
kubectl: { available: false, context: null, errors: [connectionError?.message, error.message].filter(Boolean) },
|
|
51
|
+
resources: {},
|
|
52
|
+
crds: [],
|
|
53
|
+
events: [],
|
|
54
|
+
permissions: [],
|
|
55
|
+
storage: {},
|
|
56
|
+
commands: []
|
|
57
|
+
}, { organization });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
function shouldProbeLocalModel(model) {
|
|
63
|
+
if (!model || model.status !== 'ready') return false;
|
|
64
|
+
const hasLiveConnection = Boolean(model.controller?.connection?.available || model.controller?.apiService);
|
|
65
|
+
if (!hasLiveConnection) return false;
|
|
66
|
+
const summaries = Array.isArray(model.resources) ? model.resources : [];
|
|
67
|
+
const crdKinds = new Set(['Repository', 'RunnerPool', 'Pipeline', 'Job']);
|
|
68
|
+
const crdItems = summaries
|
|
69
|
+
.filter((resource) => crdKinds.has(resource?.kind))
|
|
70
|
+
.reduce((count, resource) => count + Number(resource?.count || resource?.items?.length || 0), 0);
|
|
71
|
+
return crdItems === 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function modelResourceScore(model) {
|
|
75
|
+
if (!model) return 0;
|
|
76
|
+
const metricCount = Number(model.metrics?.resources || 0);
|
|
77
|
+
const summaryCount = Array.isArray(model.resources)
|
|
78
|
+
? model.resources.reduce((count, resource) => count + Number(resource?.count || resource?.items?.length || 0), 0)
|
|
79
|
+
: 0;
|
|
80
|
+
const dashboardCount = Number(model.views?.dashboard?.repositories?.length || 0);
|
|
81
|
+
return metricCount + summaryCount + dashboardCount;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function shouldFallbackFromRemoteModel(model) {
|
|
85
|
+
if (!model || model.status !== 'degraded') return false;
|
|
86
|
+
const hasLiveConnection = Boolean(model.controller?.connection?.available || model.controller?.apiService);
|
|
87
|
+
if (hasLiveConnection) return false;
|
|
88
|
+
const resourceCount = Number(model.metrics?.resources || 0);
|
|
89
|
+
const hasResourceItems = Array.isArray(model.resources) && model.resources.some((resource) => Number(resource?.count || 0) > 0 || resource?.items?.length);
|
|
90
|
+
const hasDashboardItems = Number(model.views?.dashboard?.repositories?.length || 0) > 0;
|
|
91
|
+
const errors = model.controller?.connection?.errors || [];
|
|
92
|
+
return resourceCount === 0 && !hasResourceItems && !hasDashboardItems && errors.length > 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function remoteControllerError(model) {
|
|
96
|
+
return (model?.controller?.connection?.errors || []).filter(Boolean).join('; ');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function unavailableControllerModel(messages, organization = null) {
|
|
100
|
+
const errors = Array.isArray(messages) ? messages : [messages];
|
|
101
|
+
return createControllerUiModel({
|
|
102
|
+
source: 'kubernetes',
|
|
103
|
+
namespace: process.env.KRATE_NAMESPACE || 'krate-system',
|
|
104
|
+
kubectl: { available: false, context: null, errors: errors.filter(Boolean) },
|
|
105
|
+
resources: {},
|
|
106
|
+
crds: [],
|
|
107
|
+
events: [],
|
|
108
|
+
permissions: [],
|
|
109
|
+
storage: {},
|
|
110
|
+
commands: []
|
|
111
|
+
}, { organization });
|
|
112
|
+
}
|