@jini-ai/platform 0.6.11 → 0.6.15-rc.1
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/CHANGELOG.md +40 -0
- package/README.md +52 -0
- package/dist/asset-cache.js +27 -13
- package/dist/command.d.ts +1 -1
- package/dist/command.js +11 -8
- package/dist/download.js +26 -32
- package/dist/fetch-with-timeout.d.ts +54 -0
- package/dist/fetch-with-timeout.js +129 -2
- package/dist/fs/directory-user-config.js +2 -1
- package/dist/fs/directory-workspace.js +3 -1
- package/dist/fs/guarded-reader.d.ts +19 -47
- package/dist/fs/guarded-reader.js +82 -13
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/mail/adapter-module.d.ts +6 -0
- package/dist/mail/delivery-wrapper.d.ts +46 -0
- package/dist/mail/delivery-wrapper.js +109 -0
- package/dist/mail/domain-read.d.ts +21 -0
- package/dist/mail/domain-read.js +17 -0
- package/dist/mail/email-sender-registry.d.ts +15 -0
- package/dist/mail/email-sender-registry.js +22 -0
- package/dist/mail/email-sender.d.ts +109 -0
- package/dist/mail/email-sender.js +10 -0
- package/dist/mail/engine-contracts.d.ts +129 -0
- package/dist/mail/engine-contracts.js +2 -0
- package/dist/mail/index.d.ts +15 -2
- package/dist/mail/index.js +8 -1
- package/dist/mail/mail-status-cache.d.ts +33 -0
- package/dist/mail/mail-status-cache.js +56 -0
- package/dist/mail/prepare-sender-email.d.ts +35 -0
- package/dist/mail/prepare-sender-email.js +68 -0
- package/dist/mail/provider-error.d.ts +18 -0
- package/dist/mail/provider-error.js +24 -0
- package/dist/mail/purpose-scoped-mailer.d.ts +12 -1
- package/dist/mail/purpose-scoped-mailer.js +23 -12
- package/dist/mail/resolve-mailer.d.ts +95 -0
- package/dist/mail/resolve-mailer.js +348 -0
- package/dist/mail/sender-domain-verification.d.ts +40 -0
- package/dist/mail/sender-domain-verification.js +71 -0
- package/dist/mail/senders.d.ts +5 -0
- package/dist/mail/senders.js +5 -0
- package/dist/mail/types.d.ts +19 -0
- package/dist/process.d.ts +27 -2
- package/dist/process.js +85 -2
- package/dist/secrets/credential-sets/vault.repo.memory.d.ts +1 -4
- package/dist/secrets/credential-sets/vault.repo.memory.js +3 -1
- package/dist/secrets/credential-sets/vault.service.d.ts +17 -9
- package/dist/secrets/credential-sets/vault.service.js +18 -9
- package/dist/secrets/credential-sets/vault.types.d.ts +10 -4
- package/dist/secrets/index.d.ts +2 -0
- package/dist/secrets/index.js +1 -0
- package/dist/secrets/passphrase-envelope.d.ts +26 -0
- package/dist/secrets/passphrase-envelope.js +125 -0
- package/package.json +8 -2
package/dist/process.js
CHANGED
|
@@ -7,12 +7,14 @@
|
|
|
7
7
|
* Windows `Get-CimInstance`), walk a process tree, and stop a set of PIDs with
|
|
8
8
|
* SIGTERM-then-SIGKILL escalation.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* Uses `command` for invocation construction and `shell`'s direct buffered
|
|
11
|
+
* executor for bounded usage samples; keeps a private `errorCode` copy.
|
|
12
12
|
*/
|
|
13
13
|
import { execFile, spawn } from "node:child_process";
|
|
14
|
+
import { posix, win32 } from "node:path";
|
|
14
15
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
15
16
|
import { createCommandInvocation } from "./command.js";
|
|
17
|
+
import { execFileBuffered } from "./shell.js";
|
|
16
18
|
/** @internal Extract a Node `error.code` as a string, or `null` when the value carries no code. */
|
|
17
19
|
function errorCode(error) {
|
|
18
20
|
if (typeof error !== "object" || error == null || !("code" in error))
|
|
@@ -285,6 +287,87 @@ export async function listProcessSnapshots() {
|
|
|
285
287
|
return [];
|
|
286
288
|
}
|
|
287
289
|
}
|
|
290
|
+
/** @internal Decode ps cumulative CPU time, including macOS fractional seconds and days. */
|
|
291
|
+
function parseCpuTime(value) {
|
|
292
|
+
const match = value.match(/^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)$/);
|
|
293
|
+
if (!match)
|
|
294
|
+
return NaN;
|
|
295
|
+
return Number(match[1] ?? 0) * 86400 + Number(match[2] ?? 0) * 3600
|
|
296
|
+
+ Number(match[3]) * 60 + Number(match[4]);
|
|
297
|
+
}
|
|
298
|
+
/** @internal Reject incomplete metrics instead of presenting missing usage as zero. */
|
|
299
|
+
function validProcessUsage(usage) {
|
|
300
|
+
const { pid, ppid, rssBytes, cpuSeconds } = usage;
|
|
301
|
+
return [pid, ppid].every(Number.isSafeInteger) && [rssBytes, cpuSeconds].every(Number.isFinite)
|
|
302
|
+
&& [pid, ppid, rssBytes, cpuSeconds].every((value) => value >= 0);
|
|
303
|
+
}
|
|
304
|
+
/** @internal Read only comm, never args/command, so credentials cannot enter the result. */
|
|
305
|
+
function parsePsUsage(stdout) {
|
|
306
|
+
return stdout.split(/\r?\n/).flatMap((line) => {
|
|
307
|
+
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(\S.*)$/);
|
|
308
|
+
if (!match)
|
|
309
|
+
return [];
|
|
310
|
+
const usage = {
|
|
311
|
+
pid: Number(match[1]), ppid: Number(match[2]), rssBytes: Number(match[3]) * 1024,
|
|
312
|
+
cpuSeconds: parseCpuTime(match[4]), name: posix.basename(match[5].trim()),
|
|
313
|
+
};
|
|
314
|
+
return validProcessUsage(usage) ? [usage] : [];
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
/** @internal CIM serializes numeric fields as numbers or strings; null is not zero usage. */
|
|
318
|
+
function cimNumber(value) {
|
|
319
|
+
if (typeof value === "number")
|
|
320
|
+
return value;
|
|
321
|
+
return typeof value === "string" && value.trim() ? Number(value) : NaN;
|
|
322
|
+
}
|
|
323
|
+
/** @internal Convert Windows 100ns CPU counters and retain nameless process rows. */
|
|
324
|
+
function parseCimUsage(stdout) {
|
|
325
|
+
if (!stdout.trim())
|
|
326
|
+
return [];
|
|
327
|
+
const payload = JSON.parse(stdout);
|
|
328
|
+
return (Array.isArray(payload) ? payload : [payload]).flatMap((record) => {
|
|
329
|
+
if (!record || typeof record !== "object")
|
|
330
|
+
return [];
|
|
331
|
+
const row = record;
|
|
332
|
+
const usage = {
|
|
333
|
+
pid: cimNumber(row.ProcessId), ppid: cimNumber(row.ParentProcessId),
|
|
334
|
+
rssBytes: cimNumber(row.WorkingSetSize),
|
|
335
|
+
cpuSeconds: (cimNumber(row.UserModeTime) + cimNumber(row.KernelModeTime)) / 10_000_000,
|
|
336
|
+
name: typeof row.Name === "string" ? win32.basename(row.Name.trim()) : "",
|
|
337
|
+
};
|
|
338
|
+
return validProcessUsage(usage) ? [usage] : [];
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
/** @internal Fixed executable and argv, invoked directly without a command shell. */
|
|
342
|
+
function processUsageCommand(windows) {
|
|
343
|
+
return windows
|
|
344
|
+
? { command: "powershell.exe", args: ["-NoProfile", "-NonInteractive", "-Command",
|
|
345
|
+
"$ErrorActionPreference = 'Stop'; Get-CimInstance Win32_Process | Select-Object ProcessId, ParentProcessId, WorkingSetSize, UserModeTime, KernelModeTime, Name | ConvertTo-Json -Compress"] }
|
|
346
|
+
: { command: "ps", args: ["-axo", "pid=,ppid=,rss=,time=,comm="] };
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Sample process RSS and cumulative CPU without reading command lines. Returns
|
|
350
|
+
* [] on command, timeout, buffer or JSON failure. RSS totals may count shared
|
|
351
|
+
* pages more than once; CPU percentages require deltas between samples.
|
|
352
|
+
* @param input - Optional host platform and bounded command-execution port.
|
|
353
|
+
* @param options - Execution timeout (default 5000ms); output is capped at 8MiB.
|
|
354
|
+
*/
|
|
355
|
+
export async function listProcessUsage(input = {}, options = {}) {
|
|
356
|
+
const windows = (input.platform ?? process.platform) === "win32";
|
|
357
|
+
const { command, args } = processUsageCommand(windows);
|
|
358
|
+
try {
|
|
359
|
+
const result = await (input.run ?? execFileBuffered)(command, args, {
|
|
360
|
+
encoding: "utf8", timeout: options.timeoutMs ?? 5000, maxBuffer: 8 * 1024 * 1024,
|
|
361
|
+
windowsHide: true,
|
|
362
|
+
});
|
|
363
|
+
if (!result.ok)
|
|
364
|
+
return [];
|
|
365
|
+
return windows ? parseCimUsage(result.stdout) : parsePsUsage(result.stdout);
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
return [];
|
|
369
|
+
}
|
|
370
|
+
}
|
|
288
371
|
/**
|
|
289
372
|
* Collect the transitive set of descendant PIDs (including the roots) from a
|
|
290
373
|
* process snapshot list, returned sorted by numeric PID descending for deterministic ordering.
|
|
@@ -24,10 +24,7 @@ export declare class InMemoryVaultRepo implements VaultRepoPort {
|
|
|
24
24
|
/** O(n) memory scan, n stored rows; clones only the default, never its siblings. */
|
|
25
25
|
findDefault(input: VaultDefaultQuery, _options?: VaultOptions): Promise<VaultRecord | null>;
|
|
26
26
|
list(input: VaultQuery, _options?: VaultOptions): Promise<VaultRecord[]>;
|
|
27
|
-
put(input:
|
|
28
|
-
record: VaultRecord;
|
|
29
|
-
expectedRevision: number | null;
|
|
30
|
-
}, _options?: VaultOptions): Promise<VaultRecord>;
|
|
27
|
+
put(input: Parameters<VaultRepoPort["put"]>[0], _options?: VaultOptions): Promise<VaultRecord>;
|
|
31
28
|
/** Synchronous mutation owner shared with batch staging; default moves never re-seal. */
|
|
32
29
|
private putRecord;
|
|
33
30
|
patchMeta(input: {
|
|
@@ -81,7 +81,9 @@ export class InMemoryVaultRepo {
|
|
|
81
81
|
const group = this.groupRows({ groupKey });
|
|
82
82
|
if (group.some(row => row.id !== record.id && row.label === record.label))
|
|
83
83
|
throw new VaultError({ code: "conflict" });
|
|
84
|
-
|
|
84
|
+
// Auto-select the first row so an ordinary save is usable without a separate default action.
|
|
85
|
+
// Explicit false opts out, keeping unattached project keys from becoming vendor defaults.
|
|
86
|
+
const saved = structuredClone({ ...record, isDefault: record.isDefault ?? (group.length === 0) });
|
|
85
87
|
// Clear first, matching a SQL partial UNIQUE index; no transient duplicate default is required.
|
|
86
88
|
if (saved.isDefault)
|
|
87
89
|
this.clearDefaults({ group });
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { KeyringPort, SecretSealerPort } from "../ports.js";
|
|
2
|
-
import { type VaultContext, type VaultIdentity, type VaultMeta, type VaultMetaParsers, type VaultCopyResult, type VaultDefaultQuery, type VaultOptions, type VaultQuery, type
|
|
2
|
+
import { type VaultContext, type VaultIdentity, type VaultMeta, type VaultMetaParsers, type VaultCopyResult, type VaultDefaultQuery, type VaultOptions, type VaultQuery, type VaultPutRecord, type VaultRepoPort, type VaultSummary } from "./vault.types.js";
|
|
3
3
|
export interface VaultServiceDeps {
|
|
4
4
|
repo: VaultRepoPort;
|
|
5
5
|
keyring: KeyringPort;
|
|
@@ -24,10 +24,10 @@ export interface VaultPutInput extends VaultContext {
|
|
|
24
24
|
* Creation requires a payload (which may be null). Payloads are always sealed JSON.
|
|
25
25
|
*/
|
|
26
26
|
payload?: VaultMeta | null | undefined;
|
|
27
|
-
/** Omitted preserves same-group default status;
|
|
28
|
-
*
|
|
27
|
+
/** Omitted preserves same-group default status; creation/provider moves let the repository
|
|
28
|
+
* apply its first-row rule. Explicit false opts out; true selects the destination default.
|
|
29
29
|
*/
|
|
30
|
-
isDefault?: boolean;
|
|
30
|
+
isDefault?: boolean | undefined;
|
|
31
31
|
/** A host-selected token, never a URI or serialized connection object. */
|
|
32
32
|
hintToken?: string;
|
|
33
33
|
}
|
|
@@ -50,6 +50,7 @@ export declare class SecretsVaultService {
|
|
|
50
50
|
constructor(deps: VaultServiceDeps, _options?: VaultOptions);
|
|
51
51
|
private authorize;
|
|
52
52
|
private parse;
|
|
53
|
+
/** Resolve a matching identity; deletion can retain legacy metadata without interpreting it. */
|
|
53
54
|
private find;
|
|
54
55
|
private seal;
|
|
55
56
|
private open;
|
|
@@ -61,14 +62,18 @@ export declare class SecretsVaultService {
|
|
|
61
62
|
* Prepared records contain envelopes and stay private to the host, never a public read model.
|
|
62
63
|
* @example const record = await vault.prepare(input); await repo.put({ record, expectedRevision: input.expectedRevision }); // inside the host transaction
|
|
63
64
|
*/
|
|
64
|
-
prepare(input: VaultPutInput, _options?: VaultOptions): Promise<
|
|
65
|
+
prepare(input: VaultPutInput, _options?: VaultOptions): Promise<VaultPutRecord>;
|
|
65
66
|
/** Resolve the target identity and apply the service-level CAS precheck. */
|
|
66
67
|
private findForPut;
|
|
67
68
|
private parsePutMeta;
|
|
68
69
|
/** Omitted payload keeps the existing envelope and hints without opening or sealing. */
|
|
69
70
|
private putEnvelope;
|
|
70
|
-
/** Authorized runtime read; returns plaintext only to the caller that supplied exact ownership.
|
|
71
|
-
|
|
71
|
+
/** Authorized runtime read; returns plaintext only to the caller that supplied exact ownership.
|
|
72
|
+
* `provider`, when given, must match the row's group before its envelope is opened (a stale
|
|
73
|
+
* selection never decrypts another group's secret). */
|
|
74
|
+
get(input: VaultIdentity, { provider }?: {
|
|
75
|
+
provider?: string;
|
|
76
|
+
}): Promise<{
|
|
72
77
|
summary: VaultSummary;
|
|
73
78
|
payload: VaultMeta | null;
|
|
74
79
|
} | null>;
|
|
@@ -103,7 +108,8 @@ export declare class SecretsVaultService {
|
|
|
103
108
|
private copyRow;
|
|
104
109
|
/** Guarded read of the source row, then a CAS write of the resealed target row. */
|
|
105
110
|
private transferRow;
|
|
106
|
-
/** Overwrite keeps the target's default, revision chain and creation time.
|
|
111
|
+
/** Overwrite keeps the target's default, revision chain and creation time. A new copy leaves
|
|
112
|
+
* placement unspecified, so the repo's first-row rule applies (never the source's default flag). */
|
|
107
113
|
private writeCopy;
|
|
108
114
|
/** Human-managed scoped inventory. No envelope or plaintext is opened or returned. */
|
|
109
115
|
list(input: VaultQuery, _options?: VaultOptions): Promise<VaultSummary[]>;
|
|
@@ -116,7 +122,9 @@ export declare class SecretsVaultService {
|
|
|
116
122
|
rotate(input: VaultIdentity & {
|
|
117
123
|
expectedRevision: number;
|
|
118
124
|
}, _options?: VaultOptions): Promise<VaultSummary>;
|
|
119
|
-
/** Idempotent for missing rows; existing rows require matching context and revision.
|
|
125
|
+
/** Idempotent for missing rows; existing rows require matching context and revision.
|
|
126
|
+
* Deletion needs no metadata interpretation, so unupgraded legacy rows remain removable.
|
|
127
|
+
*/
|
|
120
128
|
delete(input: VaultIdentity & {
|
|
121
129
|
expectedRevision: number;
|
|
122
130
|
}, _options?: VaultOptions): Promise<boolean>;
|
|
@@ -81,13 +81,15 @@ export class SecretsVaultService {
|
|
|
81
81
|
assertContext(record);
|
|
82
82
|
return { ...record, meta: this.deps.metaParsers[record.kind]({ meta: record.meta, provider: record.provider }) };
|
|
83
83
|
}
|
|
84
|
-
|
|
84
|
+
/** Resolve a matching identity; deletion can retain legacy metadata without interpreting it. */
|
|
85
|
+
async find(input, { parseMeta = true } = {}) {
|
|
85
86
|
const record = await this.deps.repo.find({ workspaceId: input.workspaceId, id: input.id });
|
|
86
87
|
if (!record)
|
|
87
88
|
return null;
|
|
88
89
|
if (buildVaultAad(input) !== buildVaultAad(record))
|
|
89
90
|
throw new VaultError({ code: "context" });
|
|
90
|
-
|
|
91
|
+
assertContext(record);
|
|
92
|
+
return parseMeta ? this.parse(record) : record;
|
|
91
93
|
}
|
|
92
94
|
async seal(input) {
|
|
93
95
|
if (input.payload === null)
|
|
@@ -137,7 +139,7 @@ export class SecretsVaultService {
|
|
|
137
139
|
const { sealed, last4, tokenLength } = await this.putEnvelope({ input, identity, existing });
|
|
138
140
|
const now = this.deps.now({});
|
|
139
141
|
return { ...identity, provider: input.provider, label: input.label, meta, sealed,
|
|
140
|
-
isDefault: input.isDefault ?? (existing !== null && existing.provider === input.provider
|
|
142
|
+
isDefault: input.isDefault ?? (existing !== null && existing.provider === input.provider ? existing.isDefault : undefined),
|
|
141
143
|
revision: (input.expectedRevision ?? 0) + 1, last4, tokenLength,
|
|
142
144
|
createdAt: existing === null ? now : existing.createdAt, updatedAt: now };
|
|
143
145
|
}
|
|
@@ -174,11 +176,15 @@ export class SecretsVaultService {
|
|
|
174
176
|
? credentialTokenHint({ token: input.hintToken }) : null;
|
|
175
177
|
return { sealed, last4: hint?.last4 ?? null, tokenLength: hint?.length ?? null };
|
|
176
178
|
}
|
|
177
|
-
/** Authorized runtime read; returns plaintext only to the caller that supplied exact ownership.
|
|
178
|
-
|
|
179
|
+
/** Authorized runtime read; returns plaintext only to the caller that supplied exact ownership.
|
|
180
|
+
* `provider`, when given, must match the row's group before its envelope is opened (a stale
|
|
181
|
+
* selection never decrypts another group's secret). */
|
|
182
|
+
async get(input, { provider } = {}) {
|
|
179
183
|
assertContext(input);
|
|
180
184
|
await this.authorize({ action: "get", context: input });
|
|
181
185
|
const record = await this.find(input);
|
|
186
|
+
if (record && provider !== undefined && record.provider !== provider)
|
|
187
|
+
throw new VaultError({ code: "context" });
|
|
182
188
|
return record ? { summary: summary(record), payload: await this.open(record) } : null;
|
|
183
189
|
}
|
|
184
190
|
/** Singleton runtime read using one provider-independent lookup. Full expected ownership and
|
|
@@ -273,14 +279,15 @@ export class SecretsVaultService {
|
|
|
273
279
|
const last4 = summary(await this.writeCopy({ source, record, targetIdentity, existing })).last4;
|
|
274
280
|
return { status: "copied", ...(last4 ? { last4 } : {}) };
|
|
275
281
|
}
|
|
276
|
-
/** Overwrite keeps the target's default, revision chain and creation time.
|
|
282
|
+
/** Overwrite keeps the target's default, revision chain and creation time. A new copy leaves
|
|
283
|
+
* placement unspecified, so the repo's first-row rule applies (never the source's default flag). */
|
|
277
284
|
async writeCopy({ source, record, targetIdentity, existing }) {
|
|
278
285
|
if (existing && !isCompatibleOverwrite({ existing, record }))
|
|
279
286
|
throw new VaultError({ code: "context" });
|
|
280
287
|
const now = this.deps.now({});
|
|
281
288
|
const placement = existing
|
|
282
289
|
? { isDefault: existing.isDefault, revision: existing.revision + 1, createdAt: existing.createdAt }
|
|
283
|
-
: { isDefault:
|
|
290
|
+
: { isDefault: undefined, revision: 1, createdAt: now };
|
|
284
291
|
const candidate = this.parse({ ...record, ...targetIdentity, ...placement, updatedAt: now });
|
|
285
292
|
candidate.sealed = await this.seal({ context: targetIdentity, payload: await source.open(record) });
|
|
286
293
|
// Reuse the source's hint policy without guessing a token field from a structured payload.
|
|
@@ -328,11 +335,13 @@ export class SecretsVaultService {
|
|
|
328
335
|
return summary(this.parse(await this.deps.repo.put({ expectedRevision: input.expectedRevision,
|
|
329
336
|
record: { ...existing, sealed, revision: existing.revision + 1, updatedAt: this.deps.now({}) } })));
|
|
330
337
|
}
|
|
331
|
-
/** Idempotent for missing rows; existing rows require matching context and revision.
|
|
338
|
+
/** Idempotent for missing rows; existing rows require matching context and revision.
|
|
339
|
+
* Deletion needs no metadata interpretation, so unupgraded legacy rows remain removable.
|
|
340
|
+
*/
|
|
332
341
|
async delete(input, _options = {}) {
|
|
333
342
|
assertContext(input);
|
|
334
343
|
await this.authorize({ action: "delete", context: input });
|
|
335
|
-
const existing = await this.find(input);
|
|
344
|
+
const existing = await this.find(input, { parseMeta: false });
|
|
336
345
|
if (!existing)
|
|
337
346
|
return false;
|
|
338
347
|
if (existing.revision !== input.expectedRevision)
|
|
@@ -42,6 +42,12 @@ export interface VaultRecord extends VaultIdentity {
|
|
|
42
42
|
export type VaultSummary = Omit<VaultRecord, "sealed"> & {
|
|
43
43
|
configured: boolean;
|
|
44
44
|
};
|
|
45
|
+
/** Unspecified placement lets the repo auto-default a group's first row; false opts out.
|
|
46
|
+
* Persisted records and read models always contain a concrete boolean.
|
|
47
|
+
*/
|
|
48
|
+
export type VaultPutRecord = Omit<VaultRecord, "isDefault"> & {
|
|
49
|
+
isDefault?: boolean | undefined;
|
|
50
|
+
};
|
|
45
51
|
export interface VaultQuery {
|
|
46
52
|
workspaceId: string;
|
|
47
53
|
kind: VaultKind;
|
|
@@ -65,7 +71,7 @@ export interface VaultSingletonQuery {
|
|
|
65
71
|
/** Ordered CAS operations; default side effects affect later operations' revisions. */
|
|
66
72
|
export type VaultBatchOp = {
|
|
67
73
|
put: {
|
|
68
|
-
record:
|
|
74
|
+
record: VaultPutRecord;
|
|
69
75
|
expectedRevision: number | null;
|
|
70
76
|
};
|
|
71
77
|
} | {
|
|
@@ -129,7 +135,7 @@ export interface VaultRepoPort {
|
|
|
129
135
|
}, options?: VaultOptions): Promise<VaultBatchResult[]>;
|
|
130
136
|
list(input: VaultQuery, options?: VaultOptions): Promise<VaultRecord[]>;
|
|
131
137
|
put(input: {
|
|
132
|
-
record:
|
|
138
|
+
record: VaultPutRecord;
|
|
133
139
|
expectedRevision: number | null;
|
|
134
140
|
}, options?: VaultOptions): Promise<VaultRecord>;
|
|
135
141
|
/** Service-validated patch: top-level keys are plain JSON-path identifiers. */
|
|
@@ -148,11 +154,11 @@ export interface VaultRepoPort {
|
|
|
148
154
|
}
|
|
149
155
|
/** Unambiguous scope encoding keeps adapter default/identity checks on the same contract. */
|
|
150
156
|
export declare function vaultGroupKey({ record }: {
|
|
151
|
-
record:
|
|
157
|
+
record: VaultDefaultQuery;
|
|
152
158
|
}, _options?: VaultOptions): string;
|
|
153
159
|
/** Adapters guard CAS and immutable identity even for callers below the service. */
|
|
154
160
|
export declare function assertVaultReplacement({ record, existing, expectedRevision }: {
|
|
155
|
-
record:
|
|
161
|
+
record: VaultPutRecord;
|
|
156
162
|
existing: VaultRecord | null;
|
|
157
163
|
expectedRevision: number | null;
|
|
158
164
|
}, _options?: VaultOptions): void;
|
package/dist/secrets/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export type { SealedSecret } from "./types.js";
|
|
2
2
|
export type { KeyringPort, SecretSealerPort } from "./ports.js";
|
|
3
3
|
export { AesGcmSecretSealer } from "./secret-sealer.aesgcm.js";
|
|
4
|
+
export { sealWithPassphrase, openWithPassphrase } from "./passphrase-envelope.js";
|
|
5
|
+
export type { PassphraseEnvelopeOptions } from "./passphrase-envelope.js";
|
|
4
6
|
export { EnvOrFileKeyring } from "./keyring.env.js";
|
|
5
7
|
export type { EnvOrFileKeyringRequired, EnvOrFileKeyringOptions } from "./keyring.env.js";
|
|
6
8
|
export type { RootKeyHandle as SiteKeyHandle } from "./ports.js";
|
package/dist/secrets/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { AesGcmSecretSealer } from "./secret-sealer.aesgcm.js";
|
|
2
|
+
export { sealWithPassphrase, openWithPassphrase } from "./passphrase-envelope.js";
|
|
2
3
|
export { EnvOrFileKeyring } from "./keyring.env.js";
|
|
3
4
|
export { FixedRootKeyKeyring as FixedSiteKeyKeyring } from "./keyring.env.js"; // site-key-legacy: implementation binding; remove on/after 2026-11-01
|
|
4
5
|
export { UnusableRootKeyError as UnusableSiteKeyError } from "./keyring.env.js"; // site-key-legacy: implementation binding; remove on/after 2026-11-01
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Optional randomness port for reproducible wire fixtures; production uses Node's CSPRNG. */
|
|
2
|
+
export interface PassphraseEnvelopeOptions {
|
|
3
|
+
randomBytesFn?: (input: {
|
|
4
|
+
byteLength: number;
|
|
5
|
+
}) => Uint8Array;
|
|
6
|
+
}
|
|
7
|
+
/** Encrypts a UTF-8 payload (up to 16 MiB) into portable JSON, without host key material.
|
|
8
|
+
* Uses asynchronous scrypt and the existing sealer; performs no filesystem or environment I/O.
|
|
9
|
+
* Rejects invalid inputs or randomness. Passphrases are not normalized; only an empty one is refused,
|
|
10
|
+
* because sealing under "" turns the export into obfuscation (strength policy stays with the host).
|
|
11
|
+
* @complexity O(n + N*r*p) time and O(n + N*r) space for n payload bytes and bounded KDF costs.
|
|
12
|
+
*/
|
|
13
|
+
export declare function sealWithPassphrase({ payload, passphrase }: {
|
|
14
|
+
payload: string;
|
|
15
|
+
passphrase: string;
|
|
16
|
+
}, options?: PassphraseEnvelopeOptions): Promise<string>;
|
|
17
|
+
/** Authenticates a portable JSON envelope before returning its exact UTF-8 payload.
|
|
18
|
+
* Rejects malformed/oversized envelopes and unsupported KDF costs before deriving a key.
|
|
19
|
+
* Wrong passphrases and modified ciphertext/header reject without returning partial plaintext.
|
|
20
|
+
* @complexity O(n + N*r*p) time and O(n + N*r) space with fixed size and KDF ceilings.
|
|
21
|
+
*/
|
|
22
|
+
export declare function openWithPassphrase({ envelope, passphrase }: {
|
|
23
|
+
envelope: string;
|
|
24
|
+
passphrase: string;
|
|
25
|
+
}, _options?: Record<string, never>): Promise<string>;
|
|
26
|
+
//# sourceMappingURL=passphrase-envelope.d.ts.map
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { randomBytes, scrypt } from "node:crypto";
|
|
2
|
+
import { FixedRootKeyKeyring } from "./keyring.env.js";
|
|
3
|
+
import { AesGcmSecretSealer } from "./secret-sealer.aesgcm.js";
|
|
4
|
+
const FORMAT = "jini-passphrase-envelope";
|
|
5
|
+
const HKDF_SALT = "jini-passphrase-envelope-v1";
|
|
6
|
+
const MAX_PAYLOAD_BYTES = 16 * 1024 * 1024;
|
|
7
|
+
const MAX_ENVELOPE_BYTES = 24 * 1024 * 1024;
|
|
8
|
+
const SCRYPT_MAX_MEMORY = 128 * 1024 * 1024;
|
|
9
|
+
/** Encrypts a UTF-8 payload (up to 16 MiB) into portable JSON, without host key material.
|
|
10
|
+
* Uses asynchronous scrypt and the existing sealer; performs no filesystem or environment I/O.
|
|
11
|
+
* Rejects invalid inputs or randomness. Passphrases are not normalized; only an empty one is refused,
|
|
12
|
+
* because sealing under "" turns the export into obfuscation (strength policy stays with the host).
|
|
13
|
+
* @complexity O(n + N*r*p) time and O(n + N*r) space for n payload bytes and bounded KDF costs.
|
|
14
|
+
*/
|
|
15
|
+
export async function sealWithPassphrase({ payload, passphrase }, options = {}) {
|
|
16
|
+
assertText(payload, MAX_PAYLOAD_BYTES);
|
|
17
|
+
assertText(passphrase, MAX_PAYLOAD_BYTES);
|
|
18
|
+
if (passphrase === "")
|
|
19
|
+
throw new TypeError("Passphrase envelope: passphrase must not be empty");
|
|
20
|
+
const randomBytesFn = options.randomBytesFn ?? (({ byteLength }) => randomBytes(byteLength));
|
|
21
|
+
const salt = Buffer.from(randomBytesFn({ byteLength: 16 }));
|
|
22
|
+
if (salt.length !== 16)
|
|
23
|
+
throw new TypeError("Passphrase envelope: salt must contain exactly 16 bytes");
|
|
24
|
+
const header = {
|
|
25
|
+
format: FORMAT, version: 1,
|
|
26
|
+
kdf: { name: "scrypt", salt: salt.toString("hex"), N: 32768, r: 8, p: 1 },
|
|
27
|
+
};
|
|
28
|
+
const keyring = await deriveKeyring({ passphrase, kdf: header.kdf });
|
|
29
|
+
const sealer = new AesGcmSecretSealer({ keyring }, { randomBytesFn });
|
|
30
|
+
const sealed = await sealer.seal({
|
|
31
|
+
plaintext: payload, key: await keyring.activeKey({}), aad: JSON.stringify(header),
|
|
32
|
+
});
|
|
33
|
+
return JSON.stringify({ header, sealed });
|
|
34
|
+
}
|
|
35
|
+
/** Authenticates a portable JSON envelope before returning its exact UTF-8 payload.
|
|
36
|
+
* Rejects malformed/oversized envelopes and unsupported KDF costs before deriving a key.
|
|
37
|
+
* Wrong passphrases and modified ciphertext/header reject without returning partial plaintext.
|
|
38
|
+
* @complexity O(n + N*r*p) time and O(n + N*r) space with fixed size and KDF ceilings.
|
|
39
|
+
*/
|
|
40
|
+
export async function openWithPassphrase({ envelope, passphrase }, _options = {}) {
|
|
41
|
+
assertText(passphrase, MAX_PAYLOAD_BYTES);
|
|
42
|
+
const parsed = parseEnvelope({ envelope });
|
|
43
|
+
const keyring = await deriveKeyring({ passphrase, kdf: parsed.header.kdf });
|
|
44
|
+
const sealer = new AesGcmSecretSealer({ keyring });
|
|
45
|
+
try {
|
|
46
|
+
return await sealer.open({ sealed: parsed.sealed }, { aad: JSON.stringify(parsed.header) });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Never include the passphrase, payload, or crypto-library error context in recovery errors.
|
|
50
|
+
throw new Error("Passphrase envelope: authentication failed");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** The format fixes r/p and accepts only bounded power-of-two N costs, preventing KDF DoS.
|
|
54
|
+
* The full header is AAD, including additional fields, so metadata cannot be silently modified.
|
|
55
|
+
*/
|
|
56
|
+
function parseEnvelope({ envelope }) {
|
|
57
|
+
assertText(envelope, MAX_ENVELOPE_BYTES);
|
|
58
|
+
let parsed;
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(envelope);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw new TypeError("Passphrase envelope: invalid JSON");
|
|
64
|
+
}
|
|
65
|
+
if (!isRecord(parsed) || !isRecord(parsed.header))
|
|
66
|
+
throw invalidEnvelope();
|
|
67
|
+
if (parsed.header.format !== FORMAT || parsed.header.version !== 1)
|
|
68
|
+
throw invalidEnvelope();
|
|
69
|
+
assertKdf(parsed.header.kdf);
|
|
70
|
+
assertSealed(parsed.sealed);
|
|
71
|
+
return parsed;
|
|
72
|
+
}
|
|
73
|
+
function assertKdf(kdf) {
|
|
74
|
+
if (!isRecord(kdf))
|
|
75
|
+
throw invalidEnvelope();
|
|
76
|
+
if (kdf.name !== "scrypt" || typeof kdf.salt !== "string")
|
|
77
|
+
throw invalidEnvelope();
|
|
78
|
+
if (!/^[a-f0-9]{32}$/i.test(kdf.salt))
|
|
79
|
+
throw invalidEnvelope();
|
|
80
|
+
if (![16384, 32768, 65536].includes(kdf.N) || kdf.r !== 8 || kdf.p !== 1) {
|
|
81
|
+
throw invalidEnvelope();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function assertSealed(sealed) {
|
|
85
|
+
if (!isRecord(sealed))
|
|
86
|
+
throw invalidEnvelope();
|
|
87
|
+
if (sealed.alg !== "aes-256-gcm" || sealed.keyId !== "v1")
|
|
88
|
+
throw invalidEnvelope();
|
|
89
|
+
assertBase64(sealed.nonce, 12, 12);
|
|
90
|
+
assertBase64(sealed.ciphertext, 16, MAX_PAYLOAD_BYTES + 16);
|
|
91
|
+
}
|
|
92
|
+
function assertBase64(value, minBytes, maxBytes) {
|
|
93
|
+
if (typeof value !== "string")
|
|
94
|
+
throw invalidEnvelope();
|
|
95
|
+
const bytes = Buffer.from(value, "base64");
|
|
96
|
+
if (bytes.length < minBytes || bytes.length > maxBytes || bytes.toString("base64") !== value) {
|
|
97
|
+
throw invalidEnvelope();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function assertText(value, maxBytes) {
|
|
101
|
+
if (typeof value !== "string")
|
|
102
|
+
throw new TypeError("Passphrase envelope: expected a string");
|
|
103
|
+
if (Buffer.byteLength(value, "utf8") > maxBytes)
|
|
104
|
+
throw new RangeError("Passphrase envelope: size limit exceeded");
|
|
105
|
+
}
|
|
106
|
+
function isRecord(value) {
|
|
107
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
108
|
+
}
|
|
109
|
+
function invalidEnvelope() {
|
|
110
|
+
return new TypeError("Passphrase envelope: invalid or unsupported envelope");
|
|
111
|
+
}
|
|
112
|
+
/** scrypt supplies only a temporary root; the existing keyring owns HKDF domain separation. */
|
|
113
|
+
async function deriveKeyring({ passphrase, kdf }) {
|
|
114
|
+
const root = await new Promise((resolve, reject) => {
|
|
115
|
+
scrypt(passphrase, Buffer.from(kdf.salt, "hex"), 32, { N: kdf.N, r: kdf.r, p: kdf.p, maxmem: SCRYPT_MAX_MEMORY }, (error, key) => error ? reject(error) : resolve(key));
|
|
116
|
+
});
|
|
117
|
+
try {
|
|
118
|
+
return new FixedRootKeyKeyring({ hex: root.toString("hex"), hkdfSalt: HKDF_SALT });
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
// The keyring copies the derived bytes; do not retain the temporary scrypt buffer as well.
|
|
122
|
+
root.fill(0);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=passphrase-envelope.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jini-ai/platform",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.15-rc.1",
|
|
4
4
|
"description": "Generic OS/platform primitives: process exec and lifecycle, filesystem containment, HTTP readiness polling, toolchain discovery, blob storage, AWS signing, and an SSRF-safe asset cache.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"./secrets/testing": "node",
|
|
23
23
|
"./http/guarded": "node",
|
|
24
24
|
"./mail": "node",
|
|
25
|
+
"./mail/senders": "universal",
|
|
25
26
|
"./mail/smtp": "node",
|
|
26
27
|
"./fs/guarded-reader": "node",
|
|
27
28
|
"./fs/durable-json": "node",
|
|
@@ -106,6 +107,11 @@
|
|
|
106
107
|
"import": "./dist/mail/index.js",
|
|
107
108
|
"default": "./dist/mail/index.js"
|
|
108
109
|
},
|
|
110
|
+
"./mail/senders": {
|
|
111
|
+
"types": "./dist/mail/senders.d.ts",
|
|
112
|
+
"import": "./dist/mail/senders.js",
|
|
113
|
+
"default": "./dist/mail/senders.js"
|
|
114
|
+
},
|
|
109
115
|
"./mail/smtp": {
|
|
110
116
|
"types": "./dist/mail/smtp.d.ts",
|
|
111
117
|
"import": "./dist/mail/smtp.js",
|
|
@@ -200,7 +206,7 @@
|
|
|
200
206
|
"!dist/**/*.map"
|
|
201
207
|
],
|
|
202
208
|
"dependencies": {
|
|
203
|
-
"@jini-ai/core": "^0.6.
|
|
209
|
+
"@jini-ai/core": "^0.6.15-rc.1"
|
|
204
210
|
},
|
|
205
211
|
"publishConfig": {
|
|
206
212
|
"access": "public",
|