@lazyingart/agent-web 0.1.40
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/LICENSE +22 -0
- package/README.md +438 -0
- package/docs/architecture.md +503 -0
- package/package.json +43 -0
- package/src/aginti-adapter.js +602 -0
- package/src/chat-context.js +1020 -0
- package/src/chat-migrations.js +947 -0
- package/src/chat-store.js +3308 -0
- package/src/cli.js +134 -0
- package/src/cloud-server.js +2043 -0
- package/src/contracts.js +103 -0
- package/src/deterministic-context-summarizer.js +254 -0
- package/src/direct-chat-capability-limits.js +66 -0
- package/src/direct-chat-contract.js +3 -0
- package/src/errors.js +50 -0
- package/src/http-contract.js +592 -0
- package/src/index.js +88 -0
- package/src/localllm-connector.js +667 -0
- package/src/migrations.js +231 -0
- package/src/operator-health.js +184 -0
- package/src/password-verifier.js +131 -0
- package/src/service-config.js +547 -0
- package/src/service.js +408 -0
- package/src/sqlite-health.js +83 -0
- package/src/storage-path.js +130 -0
- package/src/store.js +914 -0
- package/src/validation.js +181 -0
- package/src/vision-attachment.js +404 -0
- package/src/web/aginti-client.js +552 -0
- package/src/web/aginti-protocol.js +1146 -0
- package/src/web/asset-map.js +462 -0
- package/src/web/browser-app.js +6491 -0
- package/src/web/cloud-session-client.js +427 -0
- package/src/web/direct-chat-client.js +1482 -0
- package/src/web/index.js +10 -0
- package/src/web/presentation-state.js +107 -0
- package/src/web/pwa-assets.js +854 -0
- package/src/web/pwa-update-handoff-store.js +179 -0
- package/src/web/safe-rendering.js +836 -0
- package/src/web/vision-image-client.js +546 -0
- package/src/web/vision-image-sanitizer.js +168 -0
- package/src/web/web-release.js +28 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { StorageCorruptionError, UnsupportedSchemaError } from './errors.js';
|
|
4
|
+
|
|
5
|
+
export const SQLITE_APPLICATION_ID = 0x4c415757;
|
|
6
|
+
|
|
7
|
+
const INITIAL_SCHEMA = `
|
|
8
|
+
CREATE TABLE schema_migrations (
|
|
9
|
+
version INTEGER PRIMARY KEY,
|
|
10
|
+
name TEXT NOT NULL UNIQUE,
|
|
11
|
+
checksum TEXT NOT NULL CHECK (
|
|
12
|
+
length(checksum) = 64 AND checksum NOT GLOB '*[^0-9a-f]*'
|
|
13
|
+
),
|
|
14
|
+
applied_at TEXT NOT NULL
|
|
15
|
+
) STRICT;
|
|
16
|
+
|
|
17
|
+
CREATE TABLE accounts (
|
|
18
|
+
id TEXT PRIMARY KEY CHECK (length(id) BETWEEN 1 AND 128),
|
|
19
|
+
issuer TEXT NOT NULL CHECK (length(issuer) BETWEEN 1 AND 256),
|
|
20
|
+
subject TEXT NOT NULL CHECK (length(subject) BETWEEN 1 AND 512),
|
|
21
|
+
display_name TEXT CHECK (display_name IS NULL OR length(display_name) <= 256),
|
|
22
|
+
created_at TEXT NOT NULL,
|
|
23
|
+
updated_at TEXT NOT NULL,
|
|
24
|
+
UNIQUE (issuer, subject)
|
|
25
|
+
) STRICT;
|
|
26
|
+
|
|
27
|
+
CREATE TABLE browser_sessions (
|
|
28
|
+
session_digest TEXT PRIMARY KEY CHECK (
|
|
29
|
+
length(session_digest) = 64 AND session_digest NOT GLOB '*[^0-9a-f]*'
|
|
30
|
+
),
|
|
31
|
+
account_id TEXT NOT NULL,
|
|
32
|
+
csrf_digest TEXT NOT NULL CHECK (
|
|
33
|
+
length(csrf_digest) = 64 AND csrf_digest NOT GLOB '*[^0-9a-f]*'
|
|
34
|
+
),
|
|
35
|
+
created_at TEXT NOT NULL,
|
|
36
|
+
expires_at TEXT NOT NULL,
|
|
37
|
+
last_seen_at TEXT NOT NULL,
|
|
38
|
+
revoked_at TEXT,
|
|
39
|
+
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
|
|
40
|
+
) STRICT;
|
|
41
|
+
|
|
42
|
+
CREATE INDEX browser_sessions_account_expiry
|
|
43
|
+
ON browser_sessions(account_id, expires_at DESC);
|
|
44
|
+
|
|
45
|
+
CREATE TABLE thread_index (
|
|
46
|
+
thread_id TEXT PRIMARY KEY CHECK (length(thread_id) BETWEEN 1 AND 128),
|
|
47
|
+
account_id TEXT NOT NULL,
|
|
48
|
+
authority TEXT NOT NULL DEFAULT 'aginti' CHECK (authority = 'aginti'),
|
|
49
|
+
title TEXT NOT NULL DEFAULT '' CHECK (length(title) <= 120),
|
|
50
|
+
pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0, 1)),
|
|
51
|
+
routing_node_id TEXT CHECK (routing_node_id IS NULL OR length(routing_node_id) BETWEEN 1 AND 128),
|
|
52
|
+
authority_revision INTEGER CHECK (authority_revision IS NULL OR authority_revision >= 0),
|
|
53
|
+
last_run_id TEXT CHECK (last_run_id IS NULL OR length(last_run_id) BETWEEN 1 AND 128),
|
|
54
|
+
created_at TEXT NOT NULL,
|
|
55
|
+
updated_at TEXT NOT NULL,
|
|
56
|
+
last_seen_at TEXT NOT NULL,
|
|
57
|
+
UNIQUE (account_id, thread_id),
|
|
58
|
+
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
|
|
59
|
+
) STRICT;
|
|
60
|
+
|
|
61
|
+
CREATE INDEX thread_index_owner_updated
|
|
62
|
+
ON thread_index(account_id, updated_at DESC, thread_id DESC);
|
|
63
|
+
|
|
64
|
+
CREATE TABLE run_cursors (
|
|
65
|
+
run_id TEXT PRIMARY KEY CHECK (length(run_id) BETWEEN 1 AND 128),
|
|
66
|
+
account_id TEXT NOT NULL,
|
|
67
|
+
thread_id TEXT NOT NULL,
|
|
68
|
+
last_seq INTEGER NOT NULL CHECK (last_seq >= 0),
|
|
69
|
+
last_event_hash TEXT,
|
|
70
|
+
created_at TEXT NOT NULL,
|
|
71
|
+
updated_at TEXT NOT NULL,
|
|
72
|
+
CHECK (
|
|
73
|
+
(last_seq = 0 AND last_event_hash IS NULL)
|
|
74
|
+
OR (
|
|
75
|
+
last_seq > 0
|
|
76
|
+
AND length(last_event_hash) = 64
|
|
77
|
+
AND last_event_hash NOT GLOB '*[^0-9a-f]*'
|
|
78
|
+
)
|
|
79
|
+
),
|
|
80
|
+
UNIQUE (account_id, run_id),
|
|
81
|
+
FOREIGN KEY (account_id, thread_id) REFERENCES thread_index(account_id, thread_id) ON DELETE CASCADE
|
|
82
|
+
) STRICT;
|
|
83
|
+
|
|
84
|
+
CREATE INDEX run_cursors_owner_thread_updated
|
|
85
|
+
ON run_cursors(account_id, thread_id, updated_at DESC, run_id DESC);
|
|
86
|
+
|
|
87
|
+
CREATE TABLE idempotency_records (
|
|
88
|
+
account_id TEXT NOT NULL,
|
|
89
|
+
operation TEXT NOT NULL CHECK (length(operation) BETWEEN 1 AND 128),
|
|
90
|
+
key_hash TEXT NOT NULL CHECK (
|
|
91
|
+
length(key_hash) = 64 AND key_hash NOT GLOB '*[^0-9a-f]*'
|
|
92
|
+
),
|
|
93
|
+
request_hash TEXT NOT NULL CHECK (
|
|
94
|
+
length(request_hash) = 64 AND request_hash NOT GLOB '*[^0-9a-f]*'
|
|
95
|
+
),
|
|
96
|
+
outcome_code TEXT NOT NULL CHECK (outcome_code = 'succeeded'),
|
|
97
|
+
resource_kind TEXT NOT NULL CHECK (
|
|
98
|
+
resource_kind IN ('account', 'browser_session', 'thread_index')
|
|
99
|
+
),
|
|
100
|
+
resource_id TEXT NOT NULL CHECK (length(resource_id) BETWEEN 1 AND 128),
|
|
101
|
+
result_digest TEXT NOT NULL CHECK (
|
|
102
|
+
length(result_digest) = 64 AND result_digest NOT GLOB '*[^0-9a-f]*'
|
|
103
|
+
),
|
|
104
|
+
created_at TEXT NOT NULL,
|
|
105
|
+
expires_at TEXT NOT NULL,
|
|
106
|
+
PRIMARY KEY (account_id, operation, key_hash),
|
|
107
|
+
FOREIGN KEY (account_id) REFERENCES accounts(id) ON DELETE CASCADE
|
|
108
|
+
) STRICT;
|
|
109
|
+
|
|
110
|
+
CREATE INDEX idempotency_records_expiry
|
|
111
|
+
ON idempotency_records(expires_at);
|
|
112
|
+
|
|
113
|
+
CREATE INDEX idempotency_records_owner_created
|
|
114
|
+
ON idempotency_records(account_id, created_at DESC);
|
|
115
|
+
`;
|
|
116
|
+
|
|
117
|
+
function checksum(sql) {
|
|
118
|
+
return createHash('sha256').update(sql, 'utf8').digest('hex');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const MIGRATIONS = Object.freeze([
|
|
122
|
+
Object.freeze({
|
|
123
|
+
version: 1,
|
|
124
|
+
name: 'cloud_presentation_index',
|
|
125
|
+
sql: INITIAL_SCHEMA,
|
|
126
|
+
checksum: checksum(INITIAL_SCHEMA)
|
|
127
|
+
})
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
export const LATEST_SCHEMA_VERSION = MIGRATIONS.at(-1).version;
|
|
131
|
+
|
|
132
|
+
function pragmaInteger(database, pragma) {
|
|
133
|
+
const row = database.prepare(`PRAGMA ${pragma}`).get();
|
|
134
|
+
const value = row?.[pragma];
|
|
135
|
+
if (!Number.isSafeInteger(value)) {
|
|
136
|
+
throw new StorageCorruptionError(`SQLite returned an invalid ${pragma} value.`);
|
|
137
|
+
}
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function assertIntegrity(database) {
|
|
142
|
+
const integrity = database.prepare('PRAGMA integrity_check').get();
|
|
143
|
+
if (integrity?.integrity_check !== 'ok') {
|
|
144
|
+
throw new StorageCorruptionError();
|
|
145
|
+
}
|
|
146
|
+
const foreignKeyFailures = database.prepare('PRAGMA foreign_key_check').all();
|
|
147
|
+
if (foreignKeyFailures.length !== 0) {
|
|
148
|
+
throw new StorageCorruptionError('The control-plane database contains invalid ownership references.');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function existingUserTables(database) {
|
|
153
|
+
return database.prepare(`
|
|
154
|
+
SELECT name
|
|
155
|
+
FROM sqlite_schema
|
|
156
|
+
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
|
|
157
|
+
ORDER BY name
|
|
158
|
+
`).all();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function verifyMigrationLedger(database, currentVersion) {
|
|
162
|
+
let rows;
|
|
163
|
+
try {
|
|
164
|
+
rows = database.prepare(`
|
|
165
|
+
SELECT version, name, checksum
|
|
166
|
+
FROM schema_migrations
|
|
167
|
+
ORDER BY version
|
|
168
|
+
`).all();
|
|
169
|
+
} catch (error) {
|
|
170
|
+
throw new StorageCorruptionError('The migration ledger is missing or unreadable.', { cause: error });
|
|
171
|
+
}
|
|
172
|
+
if (rows.length !== currentVersion) {
|
|
173
|
+
throw new StorageCorruptionError('The migration ledger does not match the schema version.');
|
|
174
|
+
}
|
|
175
|
+
for (let index = 0; index < currentVersion; index += 1) {
|
|
176
|
+
const expected = MIGRATIONS[index];
|
|
177
|
+
const actual = rows[index];
|
|
178
|
+
if (
|
|
179
|
+
Number(actual?.version) !== expected.version ||
|
|
180
|
+
actual?.name !== expected.name ||
|
|
181
|
+
actual?.checksum !== expected.checksum
|
|
182
|
+
) {
|
|
183
|
+
throw new StorageCorruptionError(`Migration ${expected.version} failed checksum validation.`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function applyMigrations(database, appliedAt) {
|
|
189
|
+
assertIntegrity(database);
|
|
190
|
+
|
|
191
|
+
const currentVersion = pragmaInteger(database, 'user_version');
|
|
192
|
+
const applicationId = pragmaInteger(database, 'application_id');
|
|
193
|
+
if (currentVersion > LATEST_SCHEMA_VERSION) {
|
|
194
|
+
throw new UnsupportedSchemaError();
|
|
195
|
+
}
|
|
196
|
+
if (currentVersion === 0 && applicationId === 0 && existingUserTables(database).length !== 0) {
|
|
197
|
+
throw new StorageCorruptionError('Refusing to claim a non-empty, unidentified SQLite database.');
|
|
198
|
+
}
|
|
199
|
+
if (currentVersion > 0 && applicationId !== SQLITE_APPLICATION_ID) {
|
|
200
|
+
throw new StorageCorruptionError('The database belongs to a different application.');
|
|
201
|
+
}
|
|
202
|
+
if (applicationId !== 0 && applicationId !== SQLITE_APPLICATION_ID) {
|
|
203
|
+
throw new StorageCorruptionError('The database application identifier is not recognized.');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (currentVersion > 0) verifyMigrationLedger(database, currentVersion);
|
|
207
|
+
|
|
208
|
+
for (const migration of MIGRATIONS.slice(currentVersion)) {
|
|
209
|
+
database.exec('BEGIN IMMEDIATE');
|
|
210
|
+
try {
|
|
211
|
+
database.exec(migration.sql);
|
|
212
|
+
database.prepare(`
|
|
213
|
+
INSERT INTO schema_migrations(version, name, checksum, applied_at)
|
|
214
|
+
VALUES (?, ?, ?, ?)
|
|
215
|
+
`).run(migration.version, migration.name, migration.checksum, appliedAt);
|
|
216
|
+
database.exec(`PRAGMA application_id = ${SQLITE_APPLICATION_ID}`);
|
|
217
|
+
database.exec(`PRAGMA user_version = ${migration.version}`);
|
|
218
|
+
database.exec('COMMIT');
|
|
219
|
+
} catch (error) {
|
|
220
|
+
try {
|
|
221
|
+
database.exec('ROLLBACK');
|
|
222
|
+
} catch {
|
|
223
|
+
// Preserve the original migration failure.
|
|
224
|
+
}
|
|
225
|
+
throw new StorageCorruptionError(`Migration ${migration.version} could not be applied.`, { cause: error });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
verifyMigrationLedger(database, LATEST_SCHEMA_VERSION);
|
|
230
|
+
assertIntegrity(database);
|
|
231
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { COMPONENT_ID, COMPONENT_ROLE } from './contracts.js';
|
|
2
|
+
import { ControlPlaneError, ValidationError } from './errors.js';
|
|
3
|
+
import { nowIso } from './validation.js';
|
|
4
|
+
|
|
5
|
+
export const OPERATOR_HEALTH_SCHEMA = 'lazying-agent-web/operator-health/v1';
|
|
6
|
+
export const OPERATOR_HEALTH_TIMEOUT_MS = 5_000;
|
|
7
|
+
|
|
8
|
+
const RELEASE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$/u;
|
|
9
|
+
const MODEL_ALIAS_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/u;
|
|
10
|
+
const SQLITE_VERSION_PATTERN = /^\d{1,3}\.\d{1,3}\.\d{1,3}$/u;
|
|
11
|
+
const STORAGE_FAILURE_REASONS = new Set([
|
|
12
|
+
'storage_corruption',
|
|
13
|
+
'storage_security_error',
|
|
14
|
+
'unsupported_schema'
|
|
15
|
+
]);
|
|
16
|
+
const DEFAULT_CLOCK = () => new Date();
|
|
17
|
+
|
|
18
|
+
function deepFreeze(value) {
|
|
19
|
+
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
20
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
21
|
+
Object.freeze(value);
|
|
22
|
+
}
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function storageFailure(error) {
|
|
27
|
+
const reason = error instanceof ControlPlaneError && STORAGE_FAILURE_REASONS.has(error.code)
|
|
28
|
+
? error.code
|
|
29
|
+
: 'storage_unavailable';
|
|
30
|
+
return Object.freeze({ state: 'unavailable', reason });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function storageSuccess(value) {
|
|
34
|
+
if (!value || value.ready !== true
|
|
35
|
+
|| !Number.isSafeInteger(value.schemaVersion) || value.schemaVersion < 1
|
|
36
|
+
|| typeof value.sqliteVersion !== 'string'
|
|
37
|
+
|| !SQLITE_VERSION_PATTERN.test(value.sqliteVersion)) {
|
|
38
|
+
throw new TypeError('storage health response is invalid');
|
|
39
|
+
}
|
|
40
|
+
return Object.freeze({
|
|
41
|
+
state: 'ready',
|
|
42
|
+
schemaVersion: value.schemaVersion,
|
|
43
|
+
sqliteVersion: value.sqliteVersion
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function inspectStorage(probe) {
|
|
48
|
+
try {
|
|
49
|
+
return storageSuccess(await probe());
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return storageFailure(error);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function timeoutError() {
|
|
56
|
+
const error = new Error('operator dependency health probe timed out');
|
|
57
|
+
error.name = 'TimeoutError';
|
|
58
|
+
return error;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function withTimeout(probe, timeoutMs) {
|
|
62
|
+
const controller = new AbortController();
|
|
63
|
+
let timer;
|
|
64
|
+
const timeout = new Promise((_, reject) => {
|
|
65
|
+
timer = setTimeout(() => {
|
|
66
|
+
const error = timeoutError();
|
|
67
|
+
controller.abort(error);
|
|
68
|
+
reject(error);
|
|
69
|
+
}, timeoutMs);
|
|
70
|
+
});
|
|
71
|
+
try {
|
|
72
|
+
return await Promise.race([
|
|
73
|
+
Promise.resolve().then(() => probe({ signal: controller.signal })),
|
|
74
|
+
timeout
|
|
75
|
+
]);
|
|
76
|
+
} finally {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function dependencyFailure(error) {
|
|
82
|
+
return Object.freeze({
|
|
83
|
+
state: 'unavailable',
|
|
84
|
+
reason: error?.name === 'TimeoutError' ? 'timeout' : 'dependency_unavailable'
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function inspectLocalLlm(probe, timeoutMs) {
|
|
89
|
+
try {
|
|
90
|
+
const value = await withTimeout(probe, timeoutMs);
|
|
91
|
+
if (!value || typeof value.ready !== 'boolean'
|
|
92
|
+
|| !Array.isArray(value.availableModelAliases)
|
|
93
|
+
|| value.availableModelAliases.length > 64
|
|
94
|
+
|| value.availableModelAliases.some((alias) => (
|
|
95
|
+
typeof alias !== 'string' || !MODEL_ALIAS_PATTERN.test(alias)
|
|
96
|
+
))) {
|
|
97
|
+
throw new TypeError('LocalLLM health response is invalid');
|
|
98
|
+
}
|
|
99
|
+
if (!value.ready) return Object.freeze({ state: 'unavailable', reason: 'model_unavailable' });
|
|
100
|
+
return Object.freeze({
|
|
101
|
+
state: 'ready',
|
|
102
|
+
availableModelAliasCount: value.availableModelAliases.length
|
|
103
|
+
});
|
|
104
|
+
} catch (error) {
|
|
105
|
+
return dependencyFailure(error);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function inspectAginti(probe, timeoutMs) {
|
|
110
|
+
if (probe === null) return Object.freeze({ state: 'not_configured' });
|
|
111
|
+
try {
|
|
112
|
+
const value = await withTimeout(probe, timeoutMs);
|
|
113
|
+
if (!value || typeof value.enabled !== 'boolean') {
|
|
114
|
+
throw new TypeError('AgInTi health response is invalid');
|
|
115
|
+
}
|
|
116
|
+
return Object.freeze({
|
|
117
|
+
state: 'ready',
|
|
118
|
+
capabilityEnabled: value.enabled
|
|
119
|
+
});
|
|
120
|
+
} catch (error) {
|
|
121
|
+
return dependencyFailure(error);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function createOperatorHealthReport({
|
|
126
|
+
releaseId,
|
|
127
|
+
cloudIndexProbe,
|
|
128
|
+
directChatProbe,
|
|
129
|
+
localLlmProbe,
|
|
130
|
+
agintiProbe = null,
|
|
131
|
+
dependencyTimeoutMs = OPERATOR_HEALTH_TIMEOUT_MS,
|
|
132
|
+
clock = DEFAULT_CLOCK
|
|
133
|
+
} = {}) {
|
|
134
|
+
if (typeof releaseId !== 'string' || !RELEASE_ID_PATTERN.test(releaseId)) {
|
|
135
|
+
throw new ValidationError('releaseId is invalid.');
|
|
136
|
+
}
|
|
137
|
+
if (typeof cloudIndexProbe !== 'function' || typeof directChatProbe !== 'function'
|
|
138
|
+
|| typeof localLlmProbe !== 'function'
|
|
139
|
+
|| (agintiProbe !== null && typeof agintiProbe !== 'function')) {
|
|
140
|
+
throw new ValidationError('operator health probes must be functions or an absent AgInTi probe.');
|
|
141
|
+
}
|
|
142
|
+
if (!Number.isSafeInteger(dependencyTimeoutMs)
|
|
143
|
+
|| dependencyTimeoutMs < 10 || dependencyTimeoutMs > 30_000) {
|
|
144
|
+
throw new ValidationError('dependencyTimeoutMs is outside the operator health bound.');
|
|
145
|
+
}
|
|
146
|
+
if (typeof clock !== 'function') throw new ValidationError('clock must be a function.');
|
|
147
|
+
|
|
148
|
+
const checkedAt = nowIso(clock);
|
|
149
|
+
const [cloudIndexStore, directChatStore, localLlm, aginti] = await Promise.all([
|
|
150
|
+
inspectStorage(cloudIndexProbe),
|
|
151
|
+
inspectStorage(directChatProbe),
|
|
152
|
+
inspectLocalLlm(localLlmProbe, dependencyTimeoutMs),
|
|
153
|
+
inspectAginti(agintiProbe, dependencyTimeoutMs)
|
|
154
|
+
]);
|
|
155
|
+
const storageReady = cloudIndexStore.state === 'ready' && directChatStore.state === 'ready';
|
|
156
|
+
const dependenciesReady = localLlm.state === 'ready'
|
|
157
|
+
&& (aginti.state === 'ready' || aginti.state === 'not_configured');
|
|
158
|
+
|
|
159
|
+
return deepFreeze({
|
|
160
|
+
schema: OPERATOR_HEALTH_SCHEMA,
|
|
161
|
+
checkedAt,
|
|
162
|
+
status: storageReady ? (dependenciesReady ? 'ready' : 'degraded') : 'unavailable',
|
|
163
|
+
component: {
|
|
164
|
+
id: COMPONENT_ID,
|
|
165
|
+
role: COMPONENT_ROLE,
|
|
166
|
+
releaseId
|
|
167
|
+
},
|
|
168
|
+
scope: {
|
|
169
|
+
audience: 'operator',
|
|
170
|
+
publicHttpEndpoint: false,
|
|
171
|
+
staticShell: 'independent'
|
|
172
|
+
},
|
|
173
|
+
storage: { cloudIndexStore, directChatStore },
|
|
174
|
+
dependencies: {
|
|
175
|
+
localLlm,
|
|
176
|
+
aginti,
|
|
177
|
+
lazyEdge: {
|
|
178
|
+
state: 'not_probed',
|
|
179
|
+
healthClaim: false,
|
|
180
|
+
authority: 'lazyedge'
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { scrypt, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const PREFIX = 'scrypt$v=1$n=131072,r=8,p=1$';
|
|
4
|
+
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+$/u;
|
|
5
|
+
const SCRYPT_PARAMETERS = Object.freeze({
|
|
6
|
+
N: 131_072,
|
|
7
|
+
r: 8,
|
|
8
|
+
p: 1,
|
|
9
|
+
keyLength: 64,
|
|
10
|
+
maxmem: 256 * 1024 * 1024
|
|
11
|
+
});
|
|
12
|
+
const COMPATIBLE_KEY_LENGTHS = Object.freeze([32, SCRYPT_PARAMETERS.keyLength]);
|
|
13
|
+
|
|
14
|
+
function canonicalBase64Url(value, name, { minimumBytes, maximumBytes }) {
|
|
15
|
+
if (typeof value !== 'string' || !BASE64URL_PATTERN.test(value) || value.includes('=')) {
|
|
16
|
+
throw new TypeError(`${name} must be canonical unpadded base64url`);
|
|
17
|
+
}
|
|
18
|
+
let decoded;
|
|
19
|
+
try {
|
|
20
|
+
decoded = Buffer.from(value, 'base64url');
|
|
21
|
+
} catch (error) {
|
|
22
|
+
throw new TypeError(`${name} is not valid base64url`, { cause: error });
|
|
23
|
+
}
|
|
24
|
+
if (decoded.toString('base64url') !== value
|
|
25
|
+
|| decoded.byteLength < minimumBytes || decoded.byteLength > maximumBytes) {
|
|
26
|
+
decoded.fill(0);
|
|
27
|
+
throw new TypeError(`${name} has an invalid canonical length`);
|
|
28
|
+
}
|
|
29
|
+
return decoded;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseRecord(record) {
|
|
33
|
+
if (typeof record !== 'string' || record.length < PREFIX.length + 2
|
|
34
|
+
|| record.length > 1_024 || /[\s\u0000-\u001f\u007f]/u.test(record)
|
|
35
|
+
|| !record.startsWith(PREFIX)) {
|
|
36
|
+
throw new TypeError('password credential is not the required fixed scrypt record');
|
|
37
|
+
}
|
|
38
|
+
const fields = record.slice(PREFIX.length).split('$');
|
|
39
|
+
if (fields.length !== 2) throw new TypeError('password credential has an invalid field count');
|
|
40
|
+
const salt = canonicalBase64Url(fields[0], 'scrypt salt', {
|
|
41
|
+
minimumBytes: 16,
|
|
42
|
+
maximumBytes: 64
|
|
43
|
+
});
|
|
44
|
+
let digest;
|
|
45
|
+
try {
|
|
46
|
+
digest = canonicalBase64Url(fields[1], 'scrypt digest', {
|
|
47
|
+
minimumBytes: COMPATIBLE_KEY_LENGTHS[0],
|
|
48
|
+
maximumBytes: SCRYPT_PARAMETERS.keyLength
|
|
49
|
+
});
|
|
50
|
+
if (!COMPATIBLE_KEY_LENGTHS.includes(digest.byteLength)) {
|
|
51
|
+
digest.fill(0);
|
|
52
|
+
throw new TypeError('scrypt digest has an unsupported canonical length');
|
|
53
|
+
}
|
|
54
|
+
} catch (error) {
|
|
55
|
+
salt.fill(0);
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
return { salt, digest, keyLength: digest.byteLength };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function passwordInput(value) {
|
|
62
|
+
if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > 4_096) {
|
|
63
|
+
throw new TypeError('password must be a bounded string');
|
|
64
|
+
}
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function derive(password, salt, keyLength) {
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
scrypt(password, salt, keyLength, {
|
|
71
|
+
N: SCRYPT_PARAMETERS.N,
|
|
72
|
+
r: SCRYPT_PARAMETERS.r,
|
|
73
|
+
p: SCRYPT_PARAMETERS.p,
|
|
74
|
+
maxmem: SCRYPT_PARAMETERS.maxmem
|
|
75
|
+
}, (error, result) => error ? reject(error) : resolve(result));
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function abortReason(signal) {
|
|
80
|
+
return signal?.reason ?? new DOMException('password verification aborted', 'AbortError');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function createScryptPasswordVerifier(encodedHash) {
|
|
84
|
+
const parsed = parseRecord(encodedHash);
|
|
85
|
+
const salt = Buffer.from(parsed.salt);
|
|
86
|
+
const expected = Buffer.from(parsed.digest);
|
|
87
|
+
const keyLength = parsed.keyLength;
|
|
88
|
+
parsed.salt.fill(0);
|
|
89
|
+
parsed.digest.fill(0);
|
|
90
|
+
|
|
91
|
+
return Object.freeze({
|
|
92
|
+
algorithm: 'scrypt',
|
|
93
|
+
parameters: Object.freeze({
|
|
94
|
+
version: 1,
|
|
95
|
+
n: SCRYPT_PARAMETERS.N,
|
|
96
|
+
r: SCRYPT_PARAMETERS.r,
|
|
97
|
+
p: SCRYPT_PARAMETERS.p,
|
|
98
|
+
keyLength
|
|
99
|
+
}),
|
|
100
|
+
async verify(candidate, { signal } = {}) {
|
|
101
|
+
const password = passwordInput(candidate);
|
|
102
|
+
if (signal !== undefined && !(signal instanceof AbortSignal)) {
|
|
103
|
+
throw new TypeError('signal must be an AbortSignal');
|
|
104
|
+
}
|
|
105
|
+
if (signal?.aborted) throw abortReason(signal);
|
|
106
|
+
const actual = await derive(password, salt, keyLength);
|
|
107
|
+
try {
|
|
108
|
+
if (signal?.aborted) throw abortReason(signal);
|
|
109
|
+
return actual.byteLength === expected.byteLength && timingSafeEqual(actual, expected);
|
|
110
|
+
} finally {
|
|
111
|
+
actual.fill(0);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function validateScryptPasswordHash(encodedHash) {
|
|
118
|
+
const parsed = parseRecord(encodedHash);
|
|
119
|
+
parsed.salt.fill(0);
|
|
120
|
+
parsed.digest.fill(0);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const PASSWORD_SCRYPT_FORMAT = Object.freeze({
|
|
125
|
+
prefix: PREFIX,
|
|
126
|
+
version: 1,
|
|
127
|
+
n: SCRYPT_PARAMETERS.N,
|
|
128
|
+
r: SCRYPT_PARAMETERS.r,
|
|
129
|
+
p: SCRYPT_PARAMETERS.p,
|
|
130
|
+
keyLength: SCRYPT_PARAMETERS.keyLength
|
|
131
|
+
});
|