@datalayer/agent-runtimes 1.5.45 → 1.5.46
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-embed/datalayer-app-main.js +3 -3
- package/lib/portable/hosts/__tests__/fixtures/lock-with-jupyter.json +40 -0
- package/lib/portable/hosts/__tests__/fixtures/npm-ls-with-jupyter.json +21 -0
- package/lib/portable/hosts/agentClient.d.ts +167 -0
- package/lib/portable/hosts/agentClient.js +346 -0
- package/lib/portable/hosts/approvalRenderers.d.ts +43 -0
- package/lib/portable/hosts/approvalRenderers.js +180 -0
- package/lib/portable/hosts/client.d.ts +95 -0
- package/lib/portable/hosts/client.js +174 -0
- package/lib/portable/hosts/contracts.d.ts +199 -0
- package/lib/portable/hosts/contracts.js +235 -0
- package/lib/portable/hosts/contracts.json +381 -0
- package/lib/portable/hosts/deadLetters.d.ts +41 -0
- package/lib/portable/hosts/deadLetters.js +124 -0
- package/lib/portable/hosts/deliveries.d.ts +76 -0
- package/lib/portable/hosts/deliveries.js +147 -0
- package/lib/portable/hosts/forbiddenGraph.d.ts +77 -0
- package/lib/portable/hosts/forbiddenGraph.js +174 -0
- package/lib/portable/hosts/health.d.ts +35 -0
- package/lib/portable/hosts/health.js +92 -0
- package/lib/portable/hosts/index.d.ts +40 -0
- package/lib/portable/hosts/index.js +45 -0
- package/lib/portable/hosts/observability.d.ts +67 -0
- package/lib/portable/hosts/observability.js +171 -0
- package/lib/portable/hosts/postgresQueue.d.ts +55 -0
- package/lib/portable/hosts/postgresQueue.js +217 -0
- package/lib/portable/hosts/queue.d.ts +47 -0
- package/lib/portable/hosts/queue.js +63 -0
- package/lib/portable/hosts/quotas.d.ts +81 -0
- package/lib/portable/hosts/quotas.js +147 -0
- package/lib/portable/hosts/rateLimit.d.ts +13 -0
- package/lib/portable/hosts/rateLimit.js +8 -0
- package/lib/portable/hosts/resolution.d.ts +54 -0
- package/lib/portable/hosts/resolution.js +46 -0
- package/lib/portable/hosts/schema.d.ts +19 -0
- package/lib/portable/hosts/schema.js +81 -0
- package/lib/portable/hosts/secretCipher.d.ts +23 -0
- package/lib/portable/hosts/secretCipher.js +45 -0
- package/lib/portable/hosts/sessionUid.d.ts +7 -0
- package/lib/portable/hosts/sessionUid.js +28 -0
- package/lib/portable/hosts/turnState.d.ts +45 -0
- package/lib/portable/hosts/turnState.js +113 -0
- package/lib/portable/index.d.ts +2 -0
- package/lib/portable/index.js +3 -0
- package/lib/portable/semantic-events.json +480 -0
- package/lib/portable/semantic.d.ts +78 -0
- package/lib/portable/semantic.js +100 -0
- package/lib/portable/semanticOfAgUi.d.ts +41 -0
- package/lib/portable/semanticOfAgUi.js +108 -0
- package/lib/protocols/A2AAdapter.js +19 -0
- package/lib/test-setup.js +17 -14
- package/package.json +1 -1
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Liveness and readiness (SLACK §10, S-101; DISCORD §10): `/healthz` says the
|
|
7
|
+
* process answers; `/readyz` says it can take durable work now — its store
|
|
8
|
+
* and its queue answer — and never asks the host or Datalayer, so a
|
|
9
|
+
* transient outage of either does not take the pods out of service: accepted
|
|
10
|
+
* work waits in the queue instead. And the window a signed host request's
|
|
11
|
+
* timestamp must fall in. Moved from the Slack service (plans/DISCORD.md
|
|
12
|
+
* DC-004).
|
|
13
|
+
*
|
|
14
|
+
* @module portable/hosts/health
|
|
15
|
+
*/
|
|
16
|
+
import { createServer, } from 'node:http';
|
|
17
|
+
/** Each check, at most `timeoutMs`: a check that throws or is late is not ready. */
|
|
18
|
+
export async function readiness(checks, timeoutMs = 2_000) {
|
|
19
|
+
const results = {};
|
|
20
|
+
await Promise.all(Object.entries(checks).map(async ([name, check]) => {
|
|
21
|
+
let timer;
|
|
22
|
+
try {
|
|
23
|
+
results[name] = await Promise.race([
|
|
24
|
+
check(),
|
|
25
|
+
new Promise(resolve => {
|
|
26
|
+
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
27
|
+
}),
|
|
28
|
+
]);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
results[name] = false;
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
if (timer) {
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}));
|
|
39
|
+
return {
|
|
40
|
+
checks: results,
|
|
41
|
+
ready: Object.values(results).every(Boolean),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function json(response, status, body) {
|
|
45
|
+
response.writeHead(status, {
|
|
46
|
+
'cache-control': 'no-store',
|
|
47
|
+
'content-type': 'application/json',
|
|
48
|
+
});
|
|
49
|
+
response.end(JSON.stringify(body));
|
|
50
|
+
}
|
|
51
|
+
/** The health paths, and what each answers. */
|
|
52
|
+
export const HEALTH_PATHS = ['/healthz', '/readyz'];
|
|
53
|
+
/**
|
|
54
|
+
* Answer `GET /healthz` or `GET /readyz`; `false` for any other request,
|
|
55
|
+
* which the caller answers.
|
|
56
|
+
*/
|
|
57
|
+
export function answerHealth(checks, request, response) {
|
|
58
|
+
const path = (request.url ?? '').split('?')[0];
|
|
59
|
+
if (request.method !== 'GET') {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
if (path === '/healthz') {
|
|
63
|
+
json(response, 200, { status: 'ok' });
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
if (path === '/readyz') {
|
|
67
|
+
void readiness(checks).then(answer => json(response, answer.ready ? 200 : 503, answer));
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
/** A worker's own server: its health and readiness only (no host route). */
|
|
73
|
+
export function healthServer(checks) {
|
|
74
|
+
return createServer((request, response) => {
|
|
75
|
+
if (!answerHealth(checks, request, response)) {
|
|
76
|
+
response.writeHead(404);
|
|
77
|
+
response.end();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/** How far a signed host request's timestamp may be from now, either way (SLACK §7.1, 1; DISCORD §7.1, 1). */
|
|
82
|
+
export const REQUEST_WINDOW_SECONDS = 5 * 60;
|
|
83
|
+
/**
|
|
84
|
+
* Whether a request's timestamp header (Slack's `X-Slack-Request-Timestamp`,
|
|
85
|
+
* Discord's `X-Signature-Timestamp`) is within five minutes of now, in the
|
|
86
|
+
* past or the future: a replay with a skewed clock is refused too.
|
|
87
|
+
*/
|
|
88
|
+
export function timestampWithinWindow(header, nowSeconds = Math.floor(Date.now() / 1000)) {
|
|
89
|
+
const value = Number(Array.isArray(header) ? header[0] : header);
|
|
90
|
+
return (Number.isInteger(value) &&
|
|
91
|
+
Math.abs(nowSeconds - value) <= REQUEST_WINDOW_SECONDS);
|
|
92
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a chat host service (Slack, Discord) shares, on Node: the ai-agents
|
|
3
|
+
* host contracts and client, the agent client over the session API, the
|
|
4
|
+
* semantic events a turn is reduced to, approval renderers, deliveries, the
|
|
5
|
+
* turn's state, application resolution, the durable queue, quotas, secrets
|
|
6
|
+
* at rest, observability, dead letters, health, the shared schema and the
|
|
7
|
+
* forbidden-graph gate (plans/DISCORD.md §4.2, DC-004, DC-005).
|
|
8
|
+
*
|
|
9
|
+
* Imported as `@datalayer/agent-runtimes/lib/portable/hosts`, and only by a
|
|
10
|
+
* Node service, which bundles it with esbuild. It reaches `src/portable/` and
|
|
11
|
+
* this folder only, and the packages `zod`, `node:crypto` and `node:http`;
|
|
12
|
+
* never React, DOM, Jupyter, Lumino, Primer, Reactor, `pg` or another folder
|
|
13
|
+
* of Agent Runtimes (`src/__tests__/portable.unit.test.ts` holds it to that).
|
|
14
|
+
* It imports its siblings by name, never `'../index'`, whose types reach
|
|
15
|
+
* Reactor; so a service never imports `@datalayer/agent-runtimes/lib/portable`
|
|
16
|
+
* itself: what it needs of it is re-exported here.
|
|
17
|
+
*
|
|
18
|
+
* @module portable/hosts
|
|
19
|
+
*/
|
|
20
|
+
export { AgUiFrames, readRunStream, type AgUiEvent } from '../agui';
|
|
21
|
+
export * from '../semantic';
|
|
22
|
+
export { askQuestionOf, semanticOf, turnOf, type TurnInfo, } from '../semanticOfAgUi';
|
|
23
|
+
export * from './contracts';
|
|
24
|
+
export { DatalayerHostClient, HostRefusedError, type BoundHost, type HostSelf, } from './client';
|
|
25
|
+
export { agUiEventsOf, assertTrustedStreamUrl, DevelopmentCredentials, HostKeyCredentials, hostSessionOf, HostStarters, HttpAgentClient, InstallationCredentials, lastAnswer, type AgentClient, type HostCredentials, type HostKeyLookup, type HostSession, type HostShape, type HostTurnRequest, type Place, type Starter, type StarterSource, type TurnStream, } from './agentClient';
|
|
26
|
+
export { hostSessionUid } from './sessionUid';
|
|
27
|
+
export { DATALAYER_ONLY, RENDERERS, renderApproval, type RenderedAction, type RenderedField, } from './approvalRenderers';
|
|
28
|
+
export { DELIVERY_PATH, DELIVERY_ROUTE, DeliveryPoller, deliveryRoute, receiveDelivery, type BoundHostKey, type DeliveryClaimStore, type DeliveryJob, type DeliverySink, } from './deliveries';
|
|
29
|
+
export { TurnState, type TurnEnding, type TurnFile, type TurnTool, } from './turnState';
|
|
30
|
+
export { resolveApplication, usableAllowances, type AllowanceView, type ApplicationRefusal, type ApplicationResolution, type ChannelRule, } from './resolution';
|
|
31
|
+
export { MemoryWorkQueue, tablePrefixOf, type ConnectionPool, type Queryable, type QueryAnswer, type WorkQueue, } from './queue';
|
|
32
|
+
export { PostgresWorkQueue } from './postgresQueue';
|
|
33
|
+
export { DEFAULT_QUOTAS, MemoryQuotaStore, PostgresQuotaStore, quotaEntries, quotaWords, resetOf, userQuotaKey, windowOf, type QuotaEntry, type QuotaLimits, type QuotaScope, type QuotaStore, type QuotaTake, type QuotaTaken, } from './quotas';
|
|
34
|
+
export { SecretCipher, type EncryptedSecret } from './secretCipher';
|
|
35
|
+
export { Logger, LogTracer, noTracer, pseudonym, redact, redactCredentials, REDACTED, TOKEN_PATTERNS, type LogSink, type Tracer, } from './observability';
|
|
36
|
+
export { DEAD_LETTERS_USAGE, DeadLetters, deadLetters, payloadHash, runDeadLettersCli, type CliOutput, type DeadLetter, } from './deadLetters';
|
|
37
|
+
export { answerHealth, HEALTH_PATHS, healthServer, readiness, REQUEST_WINDOW_SECONDS, timestampWithinWindow, type ReadinessChecks, } from './health';
|
|
38
|
+
export { systemClock, type Clock } from './rateLimit';
|
|
39
|
+
export { applyHostSchema, hostSchemaSql, hostTables } from './schema';
|
|
40
|
+
export { FORBIDDEN, forbiddenInBundle, forbiddenInGraph, forbiddenInInstalled, importsOf, installedGraph, isForbidden, packageOf, productionGraph, type BundleMetafile, type InstalledTree, type Lockfile, type Reached, } from './forbiddenGraph';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* What a chat host service (Slack, Discord) shares, on Node: the ai-agents
|
|
7
|
+
* host contracts and client, the agent client over the session API, the
|
|
8
|
+
* semantic events a turn is reduced to, approval renderers, deliveries, the
|
|
9
|
+
* turn's state, application resolution, the durable queue, quotas, secrets
|
|
10
|
+
* at rest, observability, dead letters, health, the shared schema and the
|
|
11
|
+
* forbidden-graph gate (plans/DISCORD.md §4.2, DC-004, DC-005).
|
|
12
|
+
*
|
|
13
|
+
* Imported as `@datalayer/agent-runtimes/lib/portable/hosts`, and only by a
|
|
14
|
+
* Node service, which bundles it with esbuild. It reaches `src/portable/` and
|
|
15
|
+
* this folder only, and the packages `zod`, `node:crypto` and `node:http`;
|
|
16
|
+
* never React, DOM, Jupyter, Lumino, Primer, Reactor, `pg` or another folder
|
|
17
|
+
* of Agent Runtimes (`src/__tests__/portable.unit.test.ts` holds it to that).
|
|
18
|
+
* It imports its siblings by name, never `'../index'`, whose types reach
|
|
19
|
+
* Reactor; so a service never imports `@datalayer/agent-runtimes/lib/portable`
|
|
20
|
+
* itself: what it needs of it is re-exported here.
|
|
21
|
+
*
|
|
22
|
+
* @module portable/hosts
|
|
23
|
+
*/
|
|
24
|
+
// The run stream and its reduction, from the portable entry's own modules.
|
|
25
|
+
export { AgUiFrames, readRunStream } from '../agui';
|
|
26
|
+
export * from '../semantic';
|
|
27
|
+
export { askQuestionOf, semanticOf, turnOf, } from '../semanticOfAgUi';
|
|
28
|
+
export * from './contracts';
|
|
29
|
+
export { DatalayerHostClient, HostRefusedError, } from './client';
|
|
30
|
+
export { agUiEventsOf, assertTrustedStreamUrl, DevelopmentCredentials, HostKeyCredentials, hostSessionOf, HostStarters, HttpAgentClient, InstallationCredentials, lastAnswer, } from './agentClient';
|
|
31
|
+
export { hostSessionUid } from './sessionUid';
|
|
32
|
+
export { DATALAYER_ONLY, RENDERERS, renderApproval, } from './approvalRenderers';
|
|
33
|
+
export { DELIVERY_PATH, DELIVERY_ROUTE, DeliveryPoller, deliveryRoute, receiveDelivery, } from './deliveries';
|
|
34
|
+
export { TurnState, } from './turnState';
|
|
35
|
+
export { resolveApplication, usableAllowances, } from './resolution';
|
|
36
|
+
export { MemoryWorkQueue, tablePrefixOf, } from './queue';
|
|
37
|
+
export { PostgresWorkQueue } from './postgresQueue';
|
|
38
|
+
export { DEFAULT_QUOTAS, MemoryQuotaStore, PostgresQuotaStore, quotaEntries, quotaWords, resetOf, userQuotaKey, windowOf, } from './quotas';
|
|
39
|
+
export { SecretCipher } from './secretCipher';
|
|
40
|
+
export { Logger, LogTracer, noTracer, pseudonym, redact, redactCredentials, REDACTED, TOKEN_PATTERNS, } from './observability';
|
|
41
|
+
export { DEAD_LETTERS_USAGE, DeadLetters, deadLetters, payloadHash, runDeadLettersCli, } from './deadLetters';
|
|
42
|
+
export { answerHealth, HEALTH_PATHS, healthServer, readiness, REQUEST_WINDOW_SECONDS, timestampWithinWindow, } from './health';
|
|
43
|
+
export { systemClock } from './rateLimit';
|
|
44
|
+
export { applyHostSchema, hostSchemaSql, hostTables } from './schema';
|
|
45
|
+
export { FORBIDDEN, forbiddenInBundle, forbiddenInGraph, forbiddenInInstalled, importsOf, installedGraph, isForbidden, packageOf, productionGraph, } from './forbiddenGraph';
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credentials by their shape, for every host at once (redacting another
|
|
3
|
+
* host's shape too is never wrong): Slack's bot, user and app tokens,
|
|
4
|
+
* Discord's bot tokens (`<base64 id>.<6>.<27+>`), Datalayer's host keys,
|
|
5
|
+
* link and handoff codes, JWTs (which a Discord interaction token is not, so
|
|
6
|
+
* it is named by its field), and bearer credentials.
|
|
7
|
+
*/
|
|
8
|
+
export declare const TOKEN_PATTERNS: readonly RegExp[];
|
|
9
|
+
export declare const REDACTED = "[redacted]";
|
|
10
|
+
/** A copy of a value with every secret field and credential-like string redacted. */
|
|
11
|
+
export declare function redact(value: unknown, depth?: number): unknown;
|
|
12
|
+
/**
|
|
13
|
+
* A copy with credentials redacted but words kept: what an audited,
|
|
14
|
+
* privileged payload read shows (S-405).
|
|
15
|
+
*/
|
|
16
|
+
export declare function redactCredentials(value: unknown, depth?: number): unknown;
|
|
17
|
+
/**
|
|
18
|
+
* A stable pseudonym of an id: the same id and salt, the same 16 hex
|
|
19
|
+
* characters; never reversible. Each host salts with its own name.
|
|
20
|
+
*/
|
|
21
|
+
export declare function pseudonym(value: string, salt: string): string;
|
|
22
|
+
export type LogSink = (line: string) => void;
|
|
23
|
+
/** A JSON logger whose every field is redacted. */
|
|
24
|
+
export declare class Logger {
|
|
25
|
+
#private;
|
|
26
|
+
readonly sink: LogSink;
|
|
27
|
+
readonly clock: () => Date;
|
|
28
|
+
/** What the audit's pseudonyms are salted with: the host's own. */
|
|
29
|
+
readonly salt: string;
|
|
30
|
+
constructor(sink?: LogSink, clock?: () => Date,
|
|
31
|
+
/** What the audit's pseudonyms are salted with: the host's own. */
|
|
32
|
+
salt?: string);
|
|
33
|
+
info(event: string, fields?: Record<string, unknown>): void;
|
|
34
|
+
warn(event: string, fields?: Record<string, unknown>): void;
|
|
35
|
+
error(event: string, fields?: Record<string, unknown>): void;
|
|
36
|
+
/**
|
|
37
|
+
* An identity audit event (SLACK S-103): who (pseudonymous), which installation
|
|
38
|
+
* (its hash), what happened, why. Never a code, a token or a principal's
|
|
39
|
+
* name.
|
|
40
|
+
*/
|
|
41
|
+
audit(event: 'identity.linked' | 'identity.unlinked' | 'identity.refused' | 'admin.changed' | 'payload.read', fields: {
|
|
42
|
+
installation: string;
|
|
43
|
+
/** The host user it is about. */
|
|
44
|
+
user?: string;
|
|
45
|
+
reason?: string;
|
|
46
|
+
by?: string;
|
|
47
|
+
[name: string]: unknown;
|
|
48
|
+
}): void;
|
|
49
|
+
}
|
|
50
|
+
/** Tracing: one span per job, named by what it is, its ids pseudonymous. */
|
|
51
|
+
export interface Tracer {
|
|
52
|
+
span<T>(name: string, attributes: Record<string, string | number | boolean>, work: () => Promise<T>): Promise<T>;
|
|
53
|
+
}
|
|
54
|
+
/** No tracing: what tests and development use. */
|
|
55
|
+
export declare const noTracer: Tracer;
|
|
56
|
+
/**
|
|
57
|
+
* Spans as log lines with their duration and outcome, until the service runs
|
|
58
|
+
* beside the plane's OTEL collector (`OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`,
|
|
59
|
+
* the Datalayer convention); each host names its attributes
|
|
60
|
+
* (`datalayer.slack.*`, `datalayer.discord.*`), pseudonymous by construction.
|
|
61
|
+
*/
|
|
62
|
+
export declare class LogTracer implements Tracer {
|
|
63
|
+
readonly logger: Logger;
|
|
64
|
+
readonly now: () => number;
|
|
65
|
+
constructor(logger: Logger, now?: () => number);
|
|
66
|
+
span<T>(name: string, attributes: Record<string, string | number | boolean>, work: () => Promise<T>): Promise<T>;
|
|
67
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2025-2026 Datalayer, Inc.
|
|
3
|
+
* Distributed under the terms of the Modified BSD License.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
/**
|
|
7
|
+
* Logs, audit events and traces without secrets or words (SLACK §9, §12.2;
|
|
8
|
+
* DISCORD §9, §12.2): every routine line is JSON whose fields pass `redact`;
|
|
9
|
+
* host and Datalayer ids are pseudonymous (`pseudonym`), so a trace can be
|
|
10
|
+
* followed `host event → job → installation hash → session → host message`
|
|
11
|
+
* without naming an installation, a person or what they wrote. Moved from
|
|
12
|
+
* the Slack service (plans/DISCORD.md DC-004).
|
|
13
|
+
*
|
|
14
|
+
* @module portable/hosts/observability
|
|
15
|
+
*/
|
|
16
|
+
/** Field names whose values are never logged: credentials, codes and words. */
|
|
17
|
+
const SECRET_NAME = /(token|secret|password|authorization|cookie|signature|^code$|_code$|host_?key|api_?key|private|^text$|^body$|prompt|message|markdown|arguments|payload|value)/i;
|
|
18
|
+
/**
|
|
19
|
+
* Credentials by their shape, for every host at once (redacting another
|
|
20
|
+
* host's shape too is never wrong): Slack's bot, user and app tokens,
|
|
21
|
+
* Discord's bot tokens (`<base64 id>.<6>.<27+>`), Datalayer's host keys,
|
|
22
|
+
* link and handoff codes, JWTs (which a Discord interaction token is not, so
|
|
23
|
+
* it is named by its field), and bearer credentials.
|
|
24
|
+
*/
|
|
25
|
+
export const TOKEN_PATTERNS = [
|
|
26
|
+
/xox[abprse]-[A-Za-z0-9-]{8,}/,
|
|
27
|
+
/xapp-[A-Za-z0-9-]{8,}/,
|
|
28
|
+
/[A-Za-z0-9_-]{23,28}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,40}/,
|
|
29
|
+
/dlh[kco]_[A-Za-z0-9_-]{8,}/,
|
|
30
|
+
/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}/,
|
|
31
|
+
/Bearer\s+[A-Za-z0-9._~+/=-]{8,}/,
|
|
32
|
+
];
|
|
33
|
+
/** Values that look like credentials wherever they appear. */
|
|
34
|
+
const SECRET_VALUE = new RegExp(`\\b(${TOKEN_PATTERNS.map(pattern => pattern.source).join('|')})`, 'g');
|
|
35
|
+
export const REDACTED = '[redacted]';
|
|
36
|
+
/** A copy of a value with every secret field and credential-like string redacted. */
|
|
37
|
+
export function redact(value, depth = 0) {
|
|
38
|
+
if (depth > 6)
|
|
39
|
+
return REDACTED;
|
|
40
|
+
if (typeof value === 'string')
|
|
41
|
+
return value.replace(SECRET_VALUE, REDACTED);
|
|
42
|
+
if (Array.isArray(value))
|
|
43
|
+
return value.map(one => redact(one, depth + 1));
|
|
44
|
+
if (value instanceof Error)
|
|
45
|
+
return { name: value.name, message: redact(value.message, depth + 1) };
|
|
46
|
+
if (value && typeof value === 'object') {
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const [name, one] of Object.entries(value))
|
|
49
|
+
out[name] = SECRET_NAME.test(name) ? REDACTED : redact(one, depth + 1);
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
/** Field names of credentials only: what even a privileged reader never sees. */
|
|
55
|
+
const CREDENTIAL_NAME = /(token|secret|password|authorization|cookie|signature|host_?key|api_?key|private_?key)/i;
|
|
56
|
+
/**
|
|
57
|
+
* A copy with credentials redacted but words kept: what an audited,
|
|
58
|
+
* privileged payload read shows (S-405).
|
|
59
|
+
*/
|
|
60
|
+
export function redactCredentials(value, depth = 0) {
|
|
61
|
+
if (depth > 8)
|
|
62
|
+
return REDACTED;
|
|
63
|
+
if (typeof value === 'string')
|
|
64
|
+
return value.replace(SECRET_VALUE, REDACTED);
|
|
65
|
+
if (Array.isArray(value))
|
|
66
|
+
return value.map(one => redactCredentials(one, depth + 1));
|
|
67
|
+
if (value && typeof value === 'object') {
|
|
68
|
+
const out = {};
|
|
69
|
+
for (const [name, one] of Object.entries(value))
|
|
70
|
+
out[name] = CREDENTIAL_NAME.test(name)
|
|
71
|
+
? REDACTED
|
|
72
|
+
: redactCredentials(one, depth + 1);
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* A stable pseudonym of an id: the same id and salt, the same 16 hex
|
|
79
|
+
* characters; never reversible. Each host salts with its own name.
|
|
80
|
+
*/
|
|
81
|
+
export function pseudonym(value, salt) {
|
|
82
|
+
return createHash('sha256')
|
|
83
|
+
.update(`${salt}:${value}`)
|
|
84
|
+
.digest('hex')
|
|
85
|
+
.slice(0, 16);
|
|
86
|
+
}
|
|
87
|
+
/** A JSON logger whose every field is redacted. */
|
|
88
|
+
export class Logger {
|
|
89
|
+
sink;
|
|
90
|
+
clock;
|
|
91
|
+
salt;
|
|
92
|
+
constructor(sink = line => process.stdout.write(`${line}\n`), clock = () => new Date(),
|
|
93
|
+
/** What the audit's pseudonyms are salted with: the host's own. */
|
|
94
|
+
salt = 'datalayer-host') {
|
|
95
|
+
this.sink = sink;
|
|
96
|
+
this.clock = clock;
|
|
97
|
+
this.salt = salt;
|
|
98
|
+
}
|
|
99
|
+
#write(level, event, fields) {
|
|
100
|
+
this.sink(JSON.stringify({
|
|
101
|
+
at: this.clock().toISOString(),
|
|
102
|
+
level,
|
|
103
|
+
event,
|
|
104
|
+
...redact(fields),
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
info(event, fields = {}) {
|
|
108
|
+
this.#write('info', event, fields);
|
|
109
|
+
}
|
|
110
|
+
warn(event, fields = {}) {
|
|
111
|
+
this.#write('warn', event, fields);
|
|
112
|
+
}
|
|
113
|
+
error(event, fields = {}) {
|
|
114
|
+
this.#write('error', event, fields);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* An identity audit event (SLACK S-103): who (pseudonymous), which installation
|
|
118
|
+
* (its hash), what happened, why. Never a code, a token or a principal's
|
|
119
|
+
* name.
|
|
120
|
+
*/
|
|
121
|
+
audit(event, fields) {
|
|
122
|
+
const { installation, user, by, ...rest } = fields;
|
|
123
|
+
this.#write('audit', event, {
|
|
124
|
+
...rest,
|
|
125
|
+
installation: pseudonym(installation, this.salt),
|
|
126
|
+
...(user ? { user: pseudonym(user, this.salt) } : {}),
|
|
127
|
+
...(by ? { by: pseudonym(by, this.salt) } : {}),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** No tracing: what tests and development use. */
|
|
132
|
+
export const noTracer = {
|
|
133
|
+
span: async (_name, _attributes, work) => work(),
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* Spans as log lines with their duration and outcome, until the service runs
|
|
137
|
+
* beside the plane's OTEL collector (`OTEL_EXPORTER_OTLP_TRACES_ENDPOINT`,
|
|
138
|
+
* the Datalayer convention); each host names its attributes
|
|
139
|
+
* (`datalayer.slack.*`, `datalayer.discord.*`), pseudonymous by construction.
|
|
140
|
+
*/
|
|
141
|
+
export class LogTracer {
|
|
142
|
+
logger;
|
|
143
|
+
now;
|
|
144
|
+
constructor(logger, now = () => performance.now()) {
|
|
145
|
+
this.logger = logger;
|
|
146
|
+
this.now = now;
|
|
147
|
+
}
|
|
148
|
+
async span(name, attributes, work) {
|
|
149
|
+
const started = this.now();
|
|
150
|
+
try {
|
|
151
|
+
const result = await work();
|
|
152
|
+
this.logger.info('span', {
|
|
153
|
+
name,
|
|
154
|
+
...attributes,
|
|
155
|
+
duration_ms: Math.round(this.now() - started),
|
|
156
|
+
outcome: 'ok',
|
|
157
|
+
});
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
this.logger.info('span', {
|
|
162
|
+
name,
|
|
163
|
+
...attributes,
|
|
164
|
+
duration_ms: Math.round(this.now() - started),
|
|
165
|
+
outcome: 'error',
|
|
166
|
+
error_name: error instanceof Error ? error.name : 'unknown',
|
|
167
|
+
});
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The durable work queue on Postgres: jobs claimed with `FOR UPDATE SKIP
|
|
3
|
+
* LOCKED`, leased and renewed while worked, retried with backoff, dead after
|
|
4
|
+
* five attempts, an ingress claim and its job kept in one transaction. Its
|
|
5
|
+
* tables are `<prefix>_jobs`, `<prefix>_event_claims` and
|
|
6
|
+
* `<prefix>_job_replays` (`hostSchemaSql`). Moved from the Slack service
|
|
7
|
+
* (plans/DISCORD.md DC-004).
|
|
8
|
+
*
|
|
9
|
+
* @module portable/hosts/postgresQueue
|
|
10
|
+
*/
|
|
11
|
+
import { type ConnectionPool, type WorkQueue } from './queue';
|
|
12
|
+
export declare class PostgresWorkQueue<T> implements WorkQueue<T> {
|
|
13
|
+
#private;
|
|
14
|
+
readonly database: ConnectionPool;
|
|
15
|
+
/** The host's table prefix: `slack`, `discord`. */
|
|
16
|
+
readonly tablePrefix: string;
|
|
17
|
+
readonly pollIntervalMs: number;
|
|
18
|
+
/**
|
|
19
|
+
* How often a job being worked renews its lease (S-207): well within the
|
|
20
|
+
* five minutes after which `reclaimAbandonedJobs` takes a job back, so
|
|
21
|
+
* only a worker that died loses its job, not one answering a long turn.
|
|
22
|
+
*/
|
|
23
|
+
readonly leaseRenewMs: number;
|
|
24
|
+
constructor(database: ConnectionPool,
|
|
25
|
+
/** The host's table prefix: `slack`, `discord`. */
|
|
26
|
+
tablePrefix: string, pollIntervalMs?: number,
|
|
27
|
+
/**
|
|
28
|
+
* How often a job being worked renews its lease (S-207): well within the
|
|
29
|
+
* five minutes after which `reclaimAbandonedJobs` takes a job back, so
|
|
30
|
+
* only a worker that died loses its job, not one answering a long turn.
|
|
31
|
+
*/
|
|
32
|
+
leaseRenewMs?: number);
|
|
33
|
+
enqueue(item: T, idempotencyKey?: string): Promise<boolean>;
|
|
34
|
+
ready(): Promise<boolean>;
|
|
35
|
+
/** Jobs that failed five times, with their metadata only: what the dead-letter tool lists. */
|
|
36
|
+
dead(limit?: number): Promise<Array<{
|
|
37
|
+
id: string;
|
|
38
|
+
attempts: number;
|
|
39
|
+
lastError: string | null;
|
|
40
|
+
createdAt: string;
|
|
41
|
+
kind: string;
|
|
42
|
+
}>>;
|
|
43
|
+
/**
|
|
44
|
+
* Put a dead job back to work: once, by id, the replay kept with who asked
|
|
45
|
+
* in the same transaction (`<prefix>_job_replays`, SLACK S-405), refused or not.
|
|
46
|
+
*/
|
|
47
|
+
replay(id: string, operator: string): Promise<boolean>;
|
|
48
|
+
/**
|
|
49
|
+
* A dead job's payload, and only a dead one's (S-405): a live job, or an id
|
|
50
|
+
* that is no job, has none to read here.
|
|
51
|
+
*/
|
|
52
|
+
payloadOf(id: string): Promise<unknown>;
|
|
53
|
+
start(handler: (item: T) => Promise<void>): void;
|
|
54
|
+
stop(): Promise<void>;
|
|
55
|
+
}
|