pi-harness-runtime 1.1.396 → 1.1.398
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/AGENTS.md +54 -7
- package/harness/notification-events.ts +6 -6
- package/index.ts +1 -1
- package/package.json +1 -1
- package/packages/a2a-adapter/package.json +1 -1
- package/packages/agent-policy/package.json +1 -1
- package/packages/architecture-generator/package.json +1 -1
- package/packages/auth/package.json +1 -1
- package/packages/autonomous-refactor/package.json +1 -1
- package/packages/autonomous-runtime/package.json +1 -1
- package/packages/cache-strategy/package.json +1 -1
- package/packages/capability-registry/package.json +1 -1
- package/packages/checkpoint/package.json +1 -1
- package/packages/cli-plugin-sdk/package.json +1 -1
- package/packages/clipboard/package.json +1 -1
- package/packages/clipboard-plugin/package.json +1 -1
- package/packages/code-generation/package.json +1 -1
- package/packages/code-review/package.json +1 -1
- package/packages/codex-adapter/package.json +1 -1
- package/packages/codex-watcher/package.json +1 -1
- package/packages/config-capture/package.json +1 -1
- package/packages/connection-runtime/package.json +1 -1
- package/packages/context-compiler/package.json +1 -1
- package/packages/context-discovery/package.json +1 -1
- package/packages/context-manager/package.json +1 -1
- package/packages/contracts/package.json +1 -1
- package/packages/cookie-sanitizer/package.json +1 -1
- package/packages/cost-optimizer/package.json +1 -1
- package/packages/dependency-analyzer/package.json +1 -1
- package/packages/django-plugin/package.json +1 -1
- package/packages/doc-generator/package.json +1 -1
- package/packages/durable-review/package.json +1 -1
- package/packages/e2e-tester/package.json +1 -1
- package/packages/evaluation-engine/package.json +1 -1
- package/packages/evaluation-runner/package.json +1 -1
- package/packages/event-bus/package.json +1 -1
- package/packages/event-sink/package.json +1 -1
- package/packages/event-store/package.json +1 -1
- package/packages/experience-replay/package.json +1 -1
- package/packages/feedback-collector/package.json +1 -1
- package/packages/file-copy-helper/package.json +1 -1
- package/packages/framework-detector/package.json +1 -1
- package/packages/framework-plugin-sdk/package.json +1 -1
- package/packages/frappe-plugin/package.json +1 -1
- package/packages/generic-web-plugin/package.json +1 -1
- package/packages/health-monitor/package.json +1 -1
- package/packages/intent-analyzer/package.json +1 -1
- package/packages/jev-judge/package.json +1 -1
- package/packages/knowledge-graph/package.json +1 -1
- package/packages/knowledge-retrieval/package.json +1 -1
- package/packages/laravel-plugin/package.json +1 -1
- package/packages/learning-engine/package.json +1 -1
- package/packages/logging/README.md +41 -6
- package/packages/logging/dist/classification.d.ts +65 -0
- package/packages/logging/dist/classification.d.ts.map +1 -0
- package/packages/logging/dist/classification.js +155 -0
- package/packages/logging/dist/classification.js.map +1 -0
- package/packages/logging/dist/config.d.ts +9 -0
- package/packages/logging/dist/config.d.ts.map +1 -1
- package/packages/logging/dist/config.js +40 -3
- package/packages/logging/dist/config.js.map +1 -1
- package/packages/logging/dist/index.d.ts +9 -1
- package/packages/logging/dist/index.d.ts.map +1 -1
- package/packages/logging/dist/index.js +10 -1
- package/packages/logging/dist/index.js.map +1 -1
- package/packages/logging/dist/ingest/schema.d.ts +60 -0
- package/packages/logging/dist/ingest/schema.d.ts.map +1 -0
- package/packages/logging/dist/ingest/schema.js +245 -0
- package/packages/logging/dist/ingest/schema.js.map +1 -0
- package/packages/logging/dist/installation.d.ts +32 -0
- package/packages/logging/dist/installation.d.ts.map +1 -0
- package/packages/logging/dist/installation.js +64 -0
- package/packages/logging/dist/installation.js.map +1 -0
- package/packages/logging/dist/sensitive-keys.d.ts +12 -0
- package/packages/logging/dist/sensitive-keys.d.ts.map +1 -0
- package/packages/logging/dist/sensitive-keys.js +39 -0
- package/packages/logging/dist/sensitive-keys.js.map +1 -0
- package/packages/logging/dist/server/credentials.d.ts +85 -0
- package/packages/logging/dist/server/credentials.d.ts.map +1 -0
- package/packages/logging/dist/server/credentials.js +147 -0
- package/packages/logging/dist/server/credentials.js.map +1 -0
- package/packages/logging/dist/server/http.d.ts +36 -0
- package/packages/logging/dist/server/http.d.ts.map +1 -0
- package/packages/logging/dist/server/http.js +139 -0
- package/packages/logging/dist/server/http.js.map +1 -0
- package/packages/logging/dist/server/index-store.d.ts +95 -0
- package/packages/logging/dist/server/index-store.d.ts.map +1 -0
- package/packages/logging/dist/server/index-store.js +190 -0
- package/packages/logging/dist/server/index-store.js.map +1 -0
- package/packages/logging/dist/server/index.d.ts +22 -0
- package/packages/logging/dist/server/index.d.ts.map +1 -0
- package/packages/logging/dist/server/index.js +15 -0
- package/packages/logging/dist/server/index.js.map +1 -0
- package/packages/logging/dist/server/keys.d.ts +22 -0
- package/packages/logging/dist/server/keys.d.ts.map +1 -0
- package/packages/logging/dist/server/keys.js +24 -0
- package/packages/logging/dist/server/keys.js.map +1 -0
- package/packages/logging/dist/server/outbox-worker.d.ts +55 -0
- package/packages/logging/dist/server/outbox-worker.d.ts.map +1 -0
- package/packages/logging/dist/server/outbox-worker.js +95 -0
- package/packages/logging/dist/server/outbox-worker.js.map +1 -0
- package/packages/logging/dist/server/outbox.d.ts +112 -0
- package/packages/logging/dist/server/outbox.d.ts.map +1 -0
- package/packages/logging/dist/server/outbox.js +247 -0
- package/packages/logging/dist/server/outbox.js.map +1 -0
- package/packages/logging/dist/server/service.d.ts +83 -0
- package/packages/logging/dist/server/service.d.ts.map +1 -0
- package/packages/logging/dist/server/service.js +144 -0
- package/packages/logging/dist/server/service.js.map +1 -0
- package/packages/logging/dist/shipper.d.ts +20 -4
- package/packages/logging/dist/shipper.d.ts.map +1 -1
- package/packages/logging/dist/shipper.js +31 -5
- package/packages/logging/dist/shipper.js.map +1 -1
- package/packages/logging/dist/singleton.d.ts.map +1 -1
- package/packages/logging/dist/singleton.js +3 -0
- package/packages/logging/dist/singleton.js.map +1 -1
- package/packages/logging/dist/types.d.ts +28 -1
- package/packages/logging/dist/types.d.ts.map +1 -1
- package/packages/logging/dist/types.js +8 -0
- package/packages/logging/dist/types.js.map +1 -1
- package/packages/logging/package.json +1 -1
- package/packages/logging/src/classification.ts +175 -0
- package/packages/logging/src/config.ts +60 -3
- package/packages/logging/src/index.ts +45 -1
- package/packages/logging/src/ingest/schema.ts +295 -0
- package/packages/logging/src/installation.ts +76 -0
- package/packages/logging/src/sensitive-keys.ts +40 -0
- package/packages/logging/src/server/credentials.ts +198 -0
- package/packages/logging/src/server/http.ts +170 -0
- package/packages/logging/src/server/index-store.ts +242 -0
- package/packages/logging/src/server/index.ts +56 -0
- package/packages/logging/src/server/keys.ts +32 -0
- package/packages/logging/src/server/outbox-worker.ts +124 -0
- package/packages/logging/src/server/outbox.ts +316 -0
- package/packages/logging/src/server/service.ts +196 -0
- package/packages/logging/src/shipper.ts +54 -8
- package/packages/logging/src/singleton.ts +3 -0
- package/packages/logging/src/types.ts +29 -1
- package/packages/logging/test/classification.test.ts +137 -0
- package/packages/logging/test/identity-transport.test.ts +197 -0
- package/packages/logging/test/ingest-schema.test.ts +184 -0
- package/packages/logging/test/integration.test.ts +9 -4
- package/packages/logging/test/log-http.test.ts +246 -0
- package/packages/logging/test/server.test.ts +444 -0
- package/packages/logging/test/shipper.test.ts +11 -11
- package/packages/logging/test/spool.test.ts +4 -1
- package/packages/mcp-adapter/package.json +1 -1
- package/packages/memory-engine/package.json +1 -1
- package/packages/milestone-manager/package.json +1 -1
- package/packages/model-registry/package.json +1 -1
- package/packages/nextjs-plugin/package.json +1 -1
- package/packages/notification/dist/adapters/detail-format.d.ts +34 -0
- package/packages/notification/dist/adapters/detail-format.d.ts.map +1 -0
- package/packages/notification/dist/adapters/detail-format.js +55 -0
- package/packages/notification/dist/adapters/detail-format.js.map +1 -0
- package/packages/notification/dist/adapters/email-adapter.d.ts.map +1 -1
- package/packages/notification/dist/adapters/email-adapter.js +3 -2
- package/packages/notification/dist/adapters/email-adapter.js.map +1 -1
- package/packages/notification/dist/adapters/line-adapter.d.ts.map +1 -1
- package/packages/notification/dist/adapters/line-adapter.js +3 -2
- package/packages/notification/dist/adapters/line-adapter.js.map +1 -1
- package/packages/notification/dist/adapters/ntfy-adapter.d.ts.map +1 -1
- package/packages/notification/dist/adapters/ntfy-adapter.js +3 -2
- package/packages/notification/dist/adapters/ntfy-adapter.js.map +1 -1
- package/packages/notification/dist/adapters/telegram-adapter.d.ts +14 -0
- package/packages/notification/dist/adapters/telegram-adapter.d.ts.map +1 -1
- package/packages/notification/dist/adapters/telegram-adapter.js +83 -67
- package/packages/notification/dist/adapters/telegram-adapter.js.map +1 -1
- package/packages/notification/dist/notification-center.d.ts.map +1 -1
- package/packages/notification/dist/notification-center.js +1 -0
- package/packages/notification/dist/notification-center.js.map +1 -1
- package/packages/notification/dist/types.d.ts +3 -0
- package/packages/notification/dist/types.d.ts.map +1 -1
- package/packages/notification/package.json +1 -1
- package/packages/notification/src/adapters/detail-format.ts +74 -0
- package/packages/notification/src/adapters/email-adapter.ts +3 -2
- package/packages/notification/src/adapters/line-adapter.ts +3 -2
- package/packages/notification/src/adapters/ntfy-adapter.ts +3 -2
- package/packages/notification/src/adapters/telegram-adapter.ts +92 -74
- package/packages/notification/src/notification-center.ts +1 -0
- package/packages/notification/src/types.ts +3 -0
- package/packages/notification/test/detail-format.test.ts +106 -0
- package/packages/observability/package.json +1 -1
- package/packages/okf-indexer/package.json +1 -1
- package/packages/paperclip-integration/package.json +1 -1
- package/packages/performance-optimizer/package.json +1 -1
- package/packages/permission-mode/package.json +1 -1
- package/packages/privilege-broker/package.json +1 -1
- package/packages/project-analyzer/package.json +1 -1
- package/packages/project-bootstrap/package.json +1 -1
- package/packages/projection-engine/package.json +1 -1
- package/packages/prompt-compiler/package.json +1 -1
- package/packages/prompt-versioning/package.json +1 -1
- package/packages/provider-adapter-sdk/package.json +1 -1
- package/packages/provider-router/package.json +1 -1
- package/packages/provider-selector/package.json +1 -1
- package/packages/providers/package.json +1 -1
- package/packages/qdrant-skills/package.json +1 -1
- package/packages/quota-manager/package.json +1 -1
- package/packages/rate-limiter/package.json +1 -1
- package/packages/react-vite-plugin/package.json +1 -1
- package/packages/relay/package.json +1 -1
- package/packages/release-manager/package.json +1 -1
- package/packages/requirement-compiler/package.json +1 -1
- package/packages/runtime/package.json +1 -1
- package/packages/scheduler/package.json +1 -1
- package/packages/scheduler-adapter/package.json +1 -1
- package/packages/session/package.json +1 -1
- package/packages/session-api/package.json +1 -1
- package/packages/session-export/package.json +1 -1
- package/packages/shared-context/package.json +1 -1
- package/packages/skill-mcp-client/package.json +1 -1
- package/packages/skill-registry/package.json +1 -1
- package/packages/skills/package.json +1 -1
- package/packages/sprint-planner/package.json +1 -1
- package/packages/subscription-engine/package.json +1 -1
- package/packages/task-compiler/package.json +1 -1
- package/packages/telegram/package.json +1 -1
- package/packages/tencentdb-memory/package.json +1 -1
- package/packages/tencentdb-sync/package.json +1 -1
- package/packages/test-data-generator/package.json +1 -1
- package/packages/test-generator/package.json +1 -1
- package/packages/todo-bd-sync/package.json +1 -1
- package/packages/token-estimation/package.json +1 -1
- package/packages/token-optimizer/package.json +1 -1
- package/packages/trajectory/package.json +1 -1
- package/packages/tui/package.json +1 -1
- package/packages/types/package.json +1 -1
- package/packages/voice-api/package.json +1 -1
- package/packages/webmcp-tester/package.json +1 -1
- package/packages/wiki/package.json +1 -1
- package/packages/workflow-events/package.json +1 -1
- package/packages/workspace-scanner/package.json +1 -1
- package/packages/worktree/package.json +1 -1
- package/packages/write-review/package.json +1 -1
- package/scripts/unified-webhook-server.ts +86 -81
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stable installation identity.
|
|
3
|
+
*
|
|
4
|
+
* One random id per local install. It is created on first use, persisted
|
|
5
|
+
* under `~/.pi-harness-runtime/installation-id.txt` and sent with every
|
|
6
|
+
* batch so the server can tell two machines of the same user apart.
|
|
7
|
+
*
|
|
8
|
+
* The id is deliberately opaque:
|
|
9
|
+
* - it never contains the hostname, the user name or a workspace path,
|
|
10
|
+
* - it is not derived from anything that could identify a person,
|
|
11
|
+
* - it is never used as an identity on the server (the tenant comes from
|
|
12
|
+
* the authenticated credential).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
|
|
20
|
+
export const INSTALLATION_ID_FILE = "installation-id.txt";
|
|
21
|
+
|
|
22
|
+
/** Default location of the installation id file. */
|
|
23
|
+
export function defaultInstallationIdPath(): string {
|
|
24
|
+
return join(homedir(), ".pi-harness-runtime", INSTALLATION_ID_FILE);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
28
|
+
|
|
29
|
+
/** True when the value looks like a uuid (the only shape we write). */
|
|
30
|
+
export function isValidInstallationId(value: string): boolean {
|
|
31
|
+
return UUID_PATTERN.test(value.trim());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ResolveInstallationIdOptions {
|
|
35
|
+
/** File the id is read from and written to. */
|
|
36
|
+
path?: string;
|
|
37
|
+
/** Id factory (tests). */
|
|
38
|
+
generate?: () => string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve the installation id: read it when it exists, create it otherwise.
|
|
43
|
+
*
|
|
44
|
+
* A failed read or write never throws - the id is best effort metadata and
|
|
45
|
+
* must not break logging.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveInstallationId(options: ResolveInstallationIdOptions = {}): string {
|
|
48
|
+
const path = options.path ?? defaultInstallationIdPath();
|
|
49
|
+
const generate = options.generate ?? randomUUID;
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
if (existsSync(path)) {
|
|
53
|
+
const existing = readFileSync(path, "utf8").trim();
|
|
54
|
+
if (isValidInstallationId(existing)) return existing;
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// Fall through and create a new id.
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let id: string;
|
|
61
|
+
try {
|
|
62
|
+
id = generate();
|
|
63
|
+
} catch {
|
|
64
|
+
id = randomUUID();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
69
|
+
writeFileSync(path, `${id}\n`, { mode: 0o600 });
|
|
70
|
+
chmodSync(path, 0o600);
|
|
71
|
+
} catch {
|
|
72
|
+
// The id still works for this process even when it cannot be persisted.
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return id;
|
|
76
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keys that never leave the machine.
|
|
3
|
+
*
|
|
4
|
+
* A key name is a strong hint about its content: "prompt", "output" or
|
|
5
|
+
* "transcript" carry private text, "path" and "url" carry the shape of the
|
|
6
|
+
* user's workspace. The list is shared by the client side classifier and the
|
|
7
|
+
* server side sanitizer, so both sides agree on what is private.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const LOCAL_ONLY_FIELD_KEYS = [
|
|
11
|
+
"prompt",
|
|
12
|
+
"response",
|
|
13
|
+
"message",
|
|
14
|
+
"content",
|
|
15
|
+
"transcript",
|
|
16
|
+
"output",
|
|
17
|
+
"stdout",
|
|
18
|
+
"stderr",
|
|
19
|
+
"command",
|
|
20
|
+
"text",
|
|
21
|
+
"body",
|
|
22
|
+
"payload",
|
|
23
|
+
"code",
|
|
24
|
+
"diff",
|
|
25
|
+
"file",
|
|
26
|
+
"path",
|
|
27
|
+
"url",
|
|
28
|
+
"uri",
|
|
29
|
+
"email",
|
|
30
|
+
"user",
|
|
31
|
+
"chat",
|
|
32
|
+
"question",
|
|
33
|
+
"answer",
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
/** True when the key is one that must not be shipped. */
|
|
37
|
+
export function isLocalOnlyFieldKey(key: string): boolean {
|
|
38
|
+
const lower = key.toLowerCase();
|
|
39
|
+
return LOCAL_ONLY_FIELD_KEYS.some((needle) => lower.includes(needle));
|
|
40
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scoped ingest credentials.
|
|
3
|
+
*
|
|
4
|
+
* The tenant is derived from the credential, never from the request body:
|
|
5
|
+
* a client cannot claim a tenant it does not own.
|
|
6
|
+
*
|
|
7
|
+
* Two credential shapes are supported:
|
|
8
|
+
*
|
|
9
|
+
* 1. A credential store (`log-ingest-credentials.json`) with one entry per
|
|
10
|
+
* installation. Tokens are stored as SHA-256 hashes, so the file is safe
|
|
11
|
+
* to keep in the key directory and a token can be revoked by removing
|
|
12
|
+
* or revoking its entry.
|
|
13
|
+
* 2. A single shared token (`LOG_INGEST_TOKEN` or the
|
|
14
|
+
* `log-ingest-token.txt` key file) for a single-tenant deployment. It
|
|
15
|
+
* maps to the tenant `LOG_INGEST_TENANT` (default `default`).
|
|
16
|
+
*
|
|
17
|
+
* When neither is configured the store is NOT configured: the ingest
|
|
18
|
+
* endpoint then refuses every write (fail closed) instead of accepting
|
|
19
|
+
* anonymous batches.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
23
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
24
|
+
import { homedir } from "node:os";
|
|
25
|
+
import { join } from "node:path";
|
|
26
|
+
|
|
27
|
+
export interface LogCredential {
|
|
28
|
+
/** Stable id of the credential (safe to log). */
|
|
29
|
+
id: string;
|
|
30
|
+
/** The tenant this credential may write for. */
|
|
31
|
+
tenantId: string;
|
|
32
|
+
/** Human readable label, e.g. "laptop". */
|
|
33
|
+
label?: string;
|
|
34
|
+
/** Allowed scopes, e.g. ["logs:write", "logs:read"]. */
|
|
35
|
+
scopes: string[];
|
|
36
|
+
/** SHA-256 hash of the token, prefixed with the algorithm. */
|
|
37
|
+
tokenHash: string;
|
|
38
|
+
createdAt: string;
|
|
39
|
+
/** Set when the credential was revoked. */
|
|
40
|
+
revokedAt?: string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CredentialFile {
|
|
44
|
+
credentials: LogCredential[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface AuthenticatedCredential {
|
|
48
|
+
credentialId: string;
|
|
49
|
+
tenantId: string;
|
|
50
|
+
scopes: string[];
|
|
51
|
+
/** True when the credential came from the shared token. */
|
|
52
|
+
shared: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface LogCredentialStoreOptions {
|
|
56
|
+
/** Path of the credential store file. */
|
|
57
|
+
path?: string;
|
|
58
|
+
/** Environment used for the shared token. */
|
|
59
|
+
env?: Record<string, string | undefined>;
|
|
60
|
+
/** Directory holding the key files. */
|
|
61
|
+
keysDir?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const DEFAULT_TENANT_ID = "default";
|
|
65
|
+
export const LOG_WRITE_SCOPE = "logs:write";
|
|
66
|
+
export const LOG_READ_SCOPE = "logs:read";
|
|
67
|
+
|
|
68
|
+
/** Hash a token the way the store does. */
|
|
69
|
+
export function hashToken(token: string): string {
|
|
70
|
+
return `sha256:${createHash("sha256").update(token, "utf8").digest("hex")}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function safeEqual(a: string, b: string): boolean {
|
|
74
|
+
const left = Buffer.from(a, "utf8");
|
|
75
|
+
const right = Buffer.from(b, "utf8");
|
|
76
|
+
if (left.length !== right.length) return false;
|
|
77
|
+
return timingSafeEqual(left, right);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function readFileTrimmed(path: string): string | undefined {
|
|
81
|
+
try {
|
|
82
|
+
if (!existsSync(path)) return undefined;
|
|
83
|
+
const value = readFileSync(path, "utf8").trim();
|
|
84
|
+
return value.length > 0 ? value : undefined;
|
|
85
|
+
} catch {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export class LogCredentialStore {
|
|
91
|
+
private readonly credentials: LogCredential[];
|
|
92
|
+
private readonly shared: { token: string; tenantId: string } | undefined;
|
|
93
|
+
|
|
94
|
+
private constructor(credentials: LogCredential[], shared?: { token: string; tenantId: string }) {
|
|
95
|
+
this.credentials = credentials;
|
|
96
|
+
this.shared = shared;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Load the store from the credential file and the shared token.
|
|
101
|
+
*
|
|
102
|
+
* A malformed credential file is ignored (and reported through
|
|
103
|
+
* `loadErrors`), never fatal: the endpoint then fails closed.
|
|
104
|
+
*/
|
|
105
|
+
static load(options: LogCredentialStoreOptions = {}): LogCredentialStore {
|
|
106
|
+
const env = options.env ?? process.env;
|
|
107
|
+
const keysDir = options.keysDir ?? join(homedir(), ".pi-harness-runtime", "keys");
|
|
108
|
+
const path = options.path ?? join(keysDir, "log-ingest-credentials.json");
|
|
109
|
+
|
|
110
|
+
const credentials: LogCredential[] = [];
|
|
111
|
+
try {
|
|
112
|
+
if (existsSync(path)) {
|
|
113
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as CredentialFile;
|
|
114
|
+
if (Array.isArray(parsed?.credentials)) {
|
|
115
|
+
for (const entry of parsed.credentials) {
|
|
116
|
+
if (
|
|
117
|
+
entry &&
|
|
118
|
+
typeof entry.id === "string" &&
|
|
119
|
+
typeof entry.tenantId === "string" &&
|
|
120
|
+
typeof entry.tokenHash === "string"
|
|
121
|
+
) {
|
|
122
|
+
credentials.push({
|
|
123
|
+
...entry,
|
|
124
|
+
scopes: Array.isArray(entry.scopes) ? entry.scopes : [LOG_WRITE_SCOPE],
|
|
125
|
+
createdAt: entry.createdAt ?? new Date(0).toISOString(),
|
|
126
|
+
revokedAt: entry.revokedAt ?? null,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
// A broken file must not open the door: the store stays unconfigured.
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const sharedToken =
|
|
137
|
+
env.LOG_INGEST_TOKEN ?? readFileTrimmed(join(keysDir, "log-ingest-token.txt"));
|
|
138
|
+
const shared = sharedToken
|
|
139
|
+
? { token: sharedToken, tenantId: env.LOG_INGEST_TENANT ?? DEFAULT_TENANT_ID }
|
|
140
|
+
: undefined;
|
|
141
|
+
|
|
142
|
+
return new LogCredentialStore(credentials, shared);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** True when at least one usable credential exists. */
|
|
146
|
+
get configured(): boolean {
|
|
147
|
+
return (
|
|
148
|
+
this.shared !== undefined ||
|
|
149
|
+
this.credentials.some((credential) => !credential.revokedAt)
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The credentials without their token hashes. */
|
|
154
|
+
list(): Omit<LogCredential, "tokenHash">[] {
|
|
155
|
+
return this.credentials.map(({ tokenHash: _tokenHash, ...rest }) => rest);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Authenticate a presented token.
|
|
160
|
+
*
|
|
161
|
+
* Returns null when the token is missing, unknown or revoked.
|
|
162
|
+
*/
|
|
163
|
+
authenticate(token: string | undefined): AuthenticatedCredential | null {
|
|
164
|
+
if (!token) return null;
|
|
165
|
+
|
|
166
|
+
if (this.shared && safeEqual(hashToken(token), hashToken(this.shared.token))) {
|
|
167
|
+
return {
|
|
168
|
+
credentialId: "shared",
|
|
169
|
+
tenantId: this.shared.tenantId,
|
|
170
|
+
scopes: [LOG_WRITE_SCOPE, LOG_READ_SCOPE],
|
|
171
|
+
shared: true,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const presented = hashToken(token);
|
|
176
|
+
for (const credential of this.credentials) {
|
|
177
|
+
if (credential.revokedAt) continue;
|
|
178
|
+
if (!safeEqual(presented, credential.tokenHash)) continue;
|
|
179
|
+
return {
|
|
180
|
+
credentialId: credential.id,
|
|
181
|
+
tenantId: credential.tenantId,
|
|
182
|
+
scopes: credential.scopes,
|
|
183
|
+
shared: false,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Extract the bearer token from an Authorization header.
|
|
193
|
+
*/
|
|
194
|
+
export function readBearerToken(header: string | undefined): string | undefined {
|
|
195
|
+
if (!header) return undefined;
|
|
196
|
+
const match = /^Bearer\s+(.+)$/i.exec(header.trim());
|
|
197
|
+
return match ? match[1].trim() : undefined;
|
|
198
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP surface of the log service.
|
|
3
|
+
*
|
|
4
|
+
* The handlers live here (not in the server script) so the whole request
|
|
5
|
+
* path - authentication, size limits, validation, durable acceptance - can
|
|
6
|
+
* be tested over a real socket.
|
|
7
|
+
*
|
|
8
|
+
* Routes:
|
|
9
|
+
* POST /api/logs accept a batch (202 after the durable write)
|
|
10
|
+
* POST /api/logs/query read-only, tenant scoped metadata query
|
|
11
|
+
* GET /api/logs/health queue depth, oldest pending age, failure count
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { appendFileSync, existsSync, mkdirSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
17
|
+
import { readBearerToken } from "./credentials.js";
|
|
18
|
+
import type { LogIndexStore, LogQueryParams } from "./index-store.js";
|
|
19
|
+
import type { LogOutbox } from "./outbox.js";
|
|
20
|
+
import type { LogIngestService } from "./service.js";
|
|
21
|
+
|
|
22
|
+
export interface LogHttpOptions {
|
|
23
|
+
service: LogIngestService;
|
|
24
|
+
outbox: LogOutbox;
|
|
25
|
+
index: LogIndexStore;
|
|
26
|
+
/** Largest accepted ingest body. Default 2 MB. */
|
|
27
|
+
maxBodyBytes?: number;
|
|
28
|
+
/** Largest accepted query body. Default 64 KB. */
|
|
29
|
+
maxQueryBytes?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Directory for a human readable copy of the accepted batches
|
|
32
|
+
* (`logs-<day>.ndjson`). Optional.
|
|
33
|
+
*/
|
|
34
|
+
localCopyDir?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type LogHttpHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
38
|
+
|
|
39
|
+
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
|
40
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
41
|
+
res.end(JSON.stringify(body));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function readBody(req: IncomingMessage, maxBytes: number): Promise<Buffer | null> {
|
|
45
|
+
const chunks: Buffer[] = [];
|
|
46
|
+
let size = 0;
|
|
47
|
+
for await (const chunk of req) {
|
|
48
|
+
size += (chunk as Buffer).length;
|
|
49
|
+
if (size > maxBytes) return null;
|
|
50
|
+
chunks.push(chunk as Buffer);
|
|
51
|
+
}
|
|
52
|
+
return Buffer.concat(chunks);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Write a human readable copy of the accepted records next to the other
|
|
57
|
+
* server logs. Best effort: a failing copy never affects the request.
|
|
58
|
+
*/
|
|
59
|
+
function writeLocalCopy(dir: string | undefined, payload: unknown): void {
|
|
60
|
+
if (!dir) return;
|
|
61
|
+
const records = (payload as { records?: unknown[] }).records;
|
|
62
|
+
if (!Array.isArray(records) || records.length === 0) return;
|
|
63
|
+
try {
|
|
64
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
65
|
+
const day = new Date().toISOString().slice(0, 10);
|
|
66
|
+
const file = join(dir, `logs-${day}.ndjson`);
|
|
67
|
+
appendFileSync(file, `${records.map((record) => JSON.stringify(record)).join("\n")}\n`);
|
|
68
|
+
} catch {
|
|
69
|
+
// The copy is a convenience, not a requirement.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build the HTTP handler for the log routes.
|
|
75
|
+
*/
|
|
76
|
+
export function createLogHttpHandler(options: LogHttpOptions): LogHttpHandler {
|
|
77
|
+
const maxBodyBytes = options.maxBodyBytes ?? 2 * 1024 * 1024;
|
|
78
|
+
const maxQueryBytes = options.maxQueryBytes ?? 64 * 1024;
|
|
79
|
+
|
|
80
|
+
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
81
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
82
|
+
const token = readBearerToken(req.headers.authorization);
|
|
83
|
+
|
|
84
|
+
if (url.pathname === "/api/logs" && req.method === "POST") {
|
|
85
|
+
// Fail closed: without a credential the endpoint does not exist.
|
|
86
|
+
if (!options.service.configured) {
|
|
87
|
+
sendJson(res, 503, { error: "log ingest is not configured", code: "ingest_unconfigured" });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (!options.service.authenticate(token)) {
|
|
91
|
+
sendJson(res, 401, { error: "unauthorized", code: "unauthorized" });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const raw = await readBody(req, maxBodyBytes);
|
|
96
|
+
if (!raw) {
|
|
97
|
+
sendJson(res, 413, { error: "payload too large", code: "payload_too_large" });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let payload: unknown;
|
|
102
|
+
try {
|
|
103
|
+
payload = JSON.parse(raw.toString());
|
|
104
|
+
} catch {
|
|
105
|
+
sendJson(res, 400, { error: "invalid json", code: "invalid_json" });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const result = options.service.ingest(payload, token);
|
|
110
|
+
sendJson(res, result.status, result.body);
|
|
111
|
+
if (result.status === 202) writeLocalCopy(options.localCopyDir, payload);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (url.pathname === "/api/logs/query" && req.method === "POST") {
|
|
116
|
+
if (!options.service.configured) {
|
|
117
|
+
sendJson(res, 503, { error: "log ingest is not configured", code: "ingest_unconfigured" });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const raw = await readBody(req, maxQueryBytes);
|
|
122
|
+
if (!raw) {
|
|
123
|
+
sendJson(res, 413, { error: "payload too large", code: "payload_too_large" });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
let params: Partial<LogQueryParams> = {};
|
|
128
|
+
if (raw.length > 0) {
|
|
129
|
+
try {
|
|
130
|
+
params = JSON.parse(raw.toString()) as Partial<LogQueryParams>;
|
|
131
|
+
} catch {
|
|
132
|
+
sendJson(res, 400, { error: "invalid json", code: "invalid_json" });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const result = options.service.query(
|
|
138
|
+
{
|
|
139
|
+
installationId: params.installationId,
|
|
140
|
+
sessionId: params.sessionId,
|
|
141
|
+
since: params.since,
|
|
142
|
+
until: params.until,
|
|
143
|
+
levels: params.levels,
|
|
144
|
+
source: params.source,
|
|
145
|
+
event: params.event,
|
|
146
|
+
limit: params.limit,
|
|
147
|
+
cursor: params.cursor,
|
|
148
|
+
},
|
|
149
|
+
token,
|
|
150
|
+
);
|
|
151
|
+
sendJson(res, result.status, result.body);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (url.pathname === "/api/logs/health" && req.method === "GET") {
|
|
156
|
+
if (!options.service.configured) {
|
|
157
|
+
sendJson(res, 503, { configured: false });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (!options.service.authenticate(token)) {
|
|
161
|
+
sendJson(res, 401, { error: "unauthorized", code: "unauthorized" });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
sendJson(res, 200, { configured: true, outbox: options.outbox.stats() });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
sendJson(res, 404, { error: "not found" });
|
|
169
|
+
};
|
|
170
|
+
}
|