@jini-ai/platform 0.5.2 → 0.5.4
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 +4 -0
- package/README.md +1 -1
- package/dist/asset-cache.d.ts +12 -1
- package/dist/asset-cache.js +9 -5
- package/dist/child-environment.d.ts +18 -0
- package/dist/child-environment.js +26 -0
- package/dist/fs/boot-identity.d.ts +10 -0
- package/dist/fs/boot-identity.js +34 -0
- package/dist/fs/directory-home.d.ts +15 -0
- package/dist/fs/directory-home.js +17 -0
- package/dist/fs/directory-registry.d.ts +50 -0
- package/dist/fs/directory-registry.js +99 -0
- package/dist/fs/directory-running-lock.d.ts +43 -0
- package/dist/fs/directory-running-lock.js +66 -0
- package/dist/fs/directory-sync.d.ts +20 -0
- package/dist/fs/directory-sync.js +87 -0
- package/dist/fs/directory-user-config.d.ts +79 -0
- package/dist/fs/directory-user-config.js +183 -0
- package/dist/fs/directory-workspace.d.ts +132 -0
- package/dist/fs/directory-workspace.js +266 -0
- package/dist/fs/durable-json.d.ts +21 -0
- package/dist/fs/durable-json.js +59 -4
- package/dist/fs/file-lock.d.ts +3 -0
- package/dist/fs/file-lock.js +16 -7
- package/dist/fs/index.d.ts +6 -0
- package/dist/fs/index.js +6 -0
- package/dist/fs/user-data-directories.d.ts +28 -0
- package/dist/fs/user-data-directories.js +53 -0
- package/dist/fs/verified-directory-move.d.ts +37 -0
- package/dist/fs/verified-directory-move.js +138 -0
- package/dist/fs/verified-file-relocation.d.ts +27 -0
- package/dist/fs/verified-file-relocation.js +115 -0
- package/dist/fs/workspace-directory.d.ts +19 -0
- package/dist/fs/workspace-directory.js +23 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/secrets/credential-sets/index.d.ts +5 -2
- package/dist/secrets/credential-sets/index.js +5 -2
- package/dist/secrets/credential-sets/vault.aad.d.ts +4 -0
- package/dist/secrets/credential-sets/vault.aad.js +8 -0
- package/dist/secrets/credential-sets/vault.meta.d.ts +10 -0
- package/dist/secrets/credential-sets/vault.meta.js +52 -0
- package/dist/secrets/credential-sets/vault.repo.memory.d.ts +51 -0
- package/dist/secrets/credential-sets/vault.repo.memory.js +148 -0
- package/dist/secrets/credential-sets/vault.service.d.ts +115 -0
- package/dist/secrets/credential-sets/vault.service.js +273 -0
- package/dist/secrets/credential-sets/vault.types.d.ts +151 -0
- package/dist/secrets/credential-sets/vault.types.js +28 -0
- package/dist/secrets/testing.d.ts +0 -1
- package/dist/secrets/testing.js +0 -1
- package/dist/web/html-document.d.ts +70 -0
- package/dist/web/html-document.js +268 -0
- package/dist/web/html-resource.d.ts +15 -0
- package/dist/web/html-resource.js +12 -0
- package/dist/web/html-to-markdown.d.ts +11 -0
- package/dist/web/html-to-markdown.js +245 -0
- package/dist/web/index.d.ts +5 -0
- package/dist/web/index.js +5 -0
- package/dist/web/web-page.d.ts +157 -0
- package/dist/web/web-page.js +310 -0
- package/package.json +14 -3
- package/dist/secrets/credential-sets/aad.d.ts +0 -7
- package/dist/secrets/credential-sets/aad.js +0 -7
- package/dist/secrets/credential-sets/repo.memory.d.ts +0 -44
- package/dist/secrets/credential-sets/repo.memory.js +0 -80
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { assertVaultReplacement, vaultGroupKey, VaultError } from "./vault.types.js";
|
|
2
|
+
/** Single-turn mutations are atomic in JS; clones prevent callers bypassing CAS through aliases. */
|
|
3
|
+
export class InMemoryVaultRepo {
|
|
4
|
+
rows = new Map();
|
|
5
|
+
singletons = new Map();
|
|
6
|
+
constructor(_required, _options = {}) { }
|
|
7
|
+
key(input) { return JSON.stringify([input.workspaceId, input.id]); }
|
|
8
|
+
async find(input, _options = {}) {
|
|
9
|
+
return structuredClone(this.rows.get(this.key(input)) ?? null);
|
|
10
|
+
}
|
|
11
|
+
singletonKey(input) {
|
|
12
|
+
return JSON.stringify([input.workspaceId, input.kind, input.ownerKey !== undefined, input.ownerKey ?? null,
|
|
13
|
+
input.principalId !== undefined, input.principalId ?? null]);
|
|
14
|
+
}
|
|
15
|
+
/** Index all four optional scope combinations; copy sets so batch staging never mutates live indexes. */
|
|
16
|
+
indexSingleton({ record, remove }, _options = {}) {
|
|
17
|
+
for (const ownerKey of [undefined, record.ownerKey])
|
|
18
|
+
for (const principalId of [undefined, record.principalId]) {
|
|
19
|
+
const scope = this.singletonKey({ workspaceId: record.workspaceId, kind: record.kind, ownerKey, principalId });
|
|
20
|
+
const ids = new Set(this.singletons.get(scope));
|
|
21
|
+
if (remove)
|
|
22
|
+
ids.delete(this.key(record));
|
|
23
|
+
else
|
|
24
|
+
ids.add(this.key(record));
|
|
25
|
+
if (ids.size)
|
|
26
|
+
this.singletons.set(scope, ids);
|
|
27
|
+
else
|
|
28
|
+
this.singletons.delete(scope);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Indexed singleton selection; clone only the selected row, ignoring provider/default. */
|
|
32
|
+
async findOne(input, _options = {}) {
|
|
33
|
+
const id = this.singletons.get(this.singletonKey(input))?.values().next().value;
|
|
34
|
+
return structuredClone(id === undefined ? null : this.rows.get(id));
|
|
35
|
+
}
|
|
36
|
+
/** Stage through the same mutation owner, then publish once. No await can expose partial rows
|
|
37
|
+
* or let a concurrent writer be lost between snapshot and swap. O(n + ops*n) fixture work.
|
|
38
|
+
*/
|
|
39
|
+
async applyBatch({ ops }, _options = {}) {
|
|
40
|
+
const staged = new InMemoryVaultRepo({});
|
|
41
|
+
staged.rows = new Map(this.rows);
|
|
42
|
+
staged.singletons = new Map(this.singletons);
|
|
43
|
+
const results = ops.map(op => {
|
|
44
|
+
if ("put" in op)
|
|
45
|
+
return staged.putRecord(op.put);
|
|
46
|
+
if ("delete" in op)
|
|
47
|
+
return staged.deleteRecord(op.delete);
|
|
48
|
+
return staged.patchRecord(op.patchMeta);
|
|
49
|
+
});
|
|
50
|
+
this.rows = staged.rows;
|
|
51
|
+
this.singletons = staged.singletons;
|
|
52
|
+
return results;
|
|
53
|
+
}
|
|
54
|
+
/** O(n) memory scan, n stored rows; clones only the default, never its siblings. */
|
|
55
|
+
async findDefault(input, _options = {}) {
|
|
56
|
+
for (const row of this.rows.values()) {
|
|
57
|
+
if (row.isDefault && row.workspaceId === input.workspaceId && row.kind === input.kind && row.ownerKey === input.ownerKey &&
|
|
58
|
+
row.principalId === input.principalId && row.provider === input.provider)
|
|
59
|
+
return structuredClone(row);
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
async list(input, _options = {}) {
|
|
64
|
+
return structuredClone([...this.rows.values()].filter(row => row.workspaceId === input.workspaceId && row.kind === input.kind &&
|
|
65
|
+
row.ownerKey === input.ownerKey && row.principalId === input.principalId && (input.provider === undefined || row.provider === input.provider))
|
|
66
|
+
.sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
67
|
+
}
|
|
68
|
+
async put(input, _options = {}) {
|
|
69
|
+
return this.putRecord(input);
|
|
70
|
+
}
|
|
71
|
+
/** Synchronous mutation owner shared with batch staging; default moves never re-seal. */
|
|
72
|
+
putRecord(input, _options = {}) {
|
|
73
|
+
const { record } = input;
|
|
74
|
+
const key = this.key(record);
|
|
75
|
+
const existing = this.rows.get(key) ?? null;
|
|
76
|
+
assertVaultReplacement({ ...input, existing });
|
|
77
|
+
const groupKey = vaultGroupKey({ record });
|
|
78
|
+
let hasGroup = false;
|
|
79
|
+
for (const row of this.rows.values())
|
|
80
|
+
if (vaultGroupKey({ record: row }) === groupKey) {
|
|
81
|
+
hasGroup = true;
|
|
82
|
+
if (row.id !== record.id && row.label === record.label)
|
|
83
|
+
throw new VaultError({ code: "conflict" });
|
|
84
|
+
}
|
|
85
|
+
const saved = structuredClone({ ...record, isDefault: record.isDefault || !hasGroup });
|
|
86
|
+
// Clear first, matching a SQL partial UNIQUE index; no transient duplicate default is required.
|
|
87
|
+
if (saved.isDefault)
|
|
88
|
+
for (const row of this.rows.values()) {
|
|
89
|
+
if (row.id !== saved.id && row.isDefault && vaultGroupKey({ record: row }) === groupKey) {
|
|
90
|
+
this.rows.set(this.key(row), { ...row, isDefault: false, revision: row.revision + 1 });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
this.rows.set(key, saved);
|
|
94
|
+
if (!existing)
|
|
95
|
+
this.indexSingleton({ record: saved, remove: false });
|
|
96
|
+
if (existing?.isDefault && vaultGroupKey({ record: existing }) !== groupKey)
|
|
97
|
+
this.promote({ record: existing });
|
|
98
|
+
return structuredClone(saved);
|
|
99
|
+
}
|
|
100
|
+
async patchMeta(input, _options = {}) {
|
|
101
|
+
return this.patchRecord(input);
|
|
102
|
+
}
|
|
103
|
+
/** Clone merged metadata before mutating the staged/live map so failures leave it intact. */
|
|
104
|
+
patchRecord(input, _options = {}) {
|
|
105
|
+
const key = this.key(input);
|
|
106
|
+
const existing = this.rows.get(key);
|
|
107
|
+
if (existing?.revision !== input.expectedRevision)
|
|
108
|
+
throw new VaultError({ code: "conflict" });
|
|
109
|
+
const saved = { ...existing, meta: structuredClone({ ...existing.meta, ...input.patch }), updatedAt: input.updatedAt, revision: existing.revision + 1 };
|
|
110
|
+
this.rows.set(key, saved);
|
|
111
|
+
return structuredClone(saved);
|
|
112
|
+
}
|
|
113
|
+
async delete(input, _options = {}) {
|
|
114
|
+
return this.deleteRecord(input);
|
|
115
|
+
}
|
|
116
|
+
/** Synchronous CAS delete shares promotion with provider moves and batch staging. */
|
|
117
|
+
deleteRecord(input, _options = {}) {
|
|
118
|
+
const key = this.key(input);
|
|
119
|
+
const existing = this.rows.get(key);
|
|
120
|
+
if (!existing)
|
|
121
|
+
return false;
|
|
122
|
+
if (existing.revision !== input.expectedRevision)
|
|
123
|
+
throw new VaultError({ code: "conflict" });
|
|
124
|
+
this.rows.delete(key);
|
|
125
|
+
this.indexSingleton({ record: existing, remove: true });
|
|
126
|
+
if (existing.isDefault)
|
|
127
|
+
this.promote({ record: existing });
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
/** O(n) winner scan; promotion changes only default/revision, matching SQL. */
|
|
131
|
+
promote({ record }, _options = {}) {
|
|
132
|
+
const groupKey = vaultGroupKey({ record });
|
|
133
|
+
let promoted;
|
|
134
|
+
// SQL BINARY/C compares UTF-8 bytes; JS's UTF-16 order differs for supplementary IDs.
|
|
135
|
+
for (const row of this.rows.values()) {
|
|
136
|
+
if (vaultGroupKey({ record: row }) === groupKey && (!promoted || row.updatedAt > promoted.updatedAt ||
|
|
137
|
+
(row.updatedAt === promoted.updatedAt && Buffer.compare(Buffer.from(row.id), Buffer.from(promoted.id)) > 0))) {
|
|
138
|
+
promoted = { id: row.id, updatedAt: row.updatedAt };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (promoted) {
|
|
142
|
+
const promotedKey = this.key({ workspaceId: record.workspaceId, id: promoted.id });
|
|
143
|
+
const row = this.rows.get(promotedKey);
|
|
144
|
+
this.rows.set(promotedKey, { ...row, isDefault: true, revision: row.revision + 1 });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
//# sourceMappingURL=vault.repo.memory.js.map
|
|
@@ -0,0 +1,115 @@
|
|
|
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 VaultRecord, type VaultRepoPort, type VaultSummary } from "./vault.types.js";
|
|
3
|
+
export interface VaultServiceDeps {
|
|
4
|
+
repo: VaultRepoPort;
|
|
5
|
+
keyring: KeyringPort;
|
|
6
|
+
sealer: SecretSealerPort;
|
|
7
|
+
metaParsers: VaultMetaParsers;
|
|
8
|
+
/** Host policy must throw on denial. No permissive default is provided. */
|
|
9
|
+
authorize: (input: {
|
|
10
|
+
action: "put" | "get" | "list" | "rotate" | "delete";
|
|
11
|
+
context: VaultContext | VaultIdentity | VaultQuery;
|
|
12
|
+
}) => Promise<void>;
|
|
13
|
+
now: (input: VaultOptions) => string;
|
|
14
|
+
newId: (input: VaultOptions) => string;
|
|
15
|
+
}
|
|
16
|
+
export interface VaultPutInput extends VaultContext {
|
|
17
|
+
/** Omit for creation with an injected ID. Replacement requires id and expectedRevision. */
|
|
18
|
+
id?: string;
|
|
19
|
+
expectedRevision: number | null;
|
|
20
|
+
provider: string | null;
|
|
21
|
+
label: string;
|
|
22
|
+
meta: unknown;
|
|
23
|
+
/** null clears; omitted on replacement preserves the envelope/hint without opening or sealing.
|
|
24
|
+
* Creation requires a payload (which may be null). Payloads are always sealed JSON.
|
|
25
|
+
*/
|
|
26
|
+
payload?: VaultMeta | null | undefined;
|
|
27
|
+
/** Omitted preserves same-group default status; a provider move starts with false and the
|
|
28
|
+
* repository applies its first-row rule. Explicit true selects the destination default.
|
|
29
|
+
*/
|
|
30
|
+
isDefault?: boolean;
|
|
31
|
+
/** A host-selected token, never a URI or serialized connection object. */
|
|
32
|
+
hintToken?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface VaultCopyInput {
|
|
35
|
+
/** This service owns the source repository, authorization and keyring-backed sealer. */
|
|
36
|
+
source: SecretsVaultService;
|
|
37
|
+
sourceContext: VaultContext;
|
|
38
|
+
targetContext: VaultContext;
|
|
39
|
+
ids: readonly string[];
|
|
40
|
+
overwrite: boolean;
|
|
41
|
+
/** Optional selection-time guard; otherwise the revisions read by this operation are used. */
|
|
42
|
+
sourceRevisions?: Readonly<Record<string, number>>;
|
|
43
|
+
}
|
|
44
|
+
/** Shared vault lifecycle. Policy/catalogs are injected; persistence and encryption are ports.
|
|
45
|
+
* Expected context is checked before opening. Normal reads have no legacy-AAD fallback.
|
|
46
|
+
* Metadata reads never need keys, and every merged patch is parsed before CAS persistence.
|
|
47
|
+
*/
|
|
48
|
+
export declare class SecretsVaultService {
|
|
49
|
+
private readonly deps;
|
|
50
|
+
constructor(deps: VaultServiceDeps, _options?: VaultOptions);
|
|
51
|
+
private authorize;
|
|
52
|
+
private parse;
|
|
53
|
+
private find;
|
|
54
|
+
private seal;
|
|
55
|
+
private open;
|
|
56
|
+
/** Create/replace configuration and a sealed JSON payload. Throws on policy, validation or CAS failure. */
|
|
57
|
+
put(input: VaultPutInput, _options?: VaultOptions): Promise<VaultSummary>;
|
|
58
|
+
/** Authorize, validate and seal before the host enters its transaction, without persisting.
|
|
59
|
+
* Replacements retain identity/createdAt; omitted payload preserves the existing envelope.
|
|
60
|
+
* Commit must CAS again because preparation does not reserve a revision or a default.
|
|
61
|
+
* Prepared records contain envelopes and stay private to the host, never a public read model.
|
|
62
|
+
* @example const record = await vault.prepare(input); await repo.put({ record, expectedRevision: input.expectedRevision }); // inside the host transaction
|
|
63
|
+
*/
|
|
64
|
+
prepare(input: VaultPutInput, _options?: VaultOptions): Promise<VaultRecord>;
|
|
65
|
+
/** Authorized runtime read; returns plaintext only to the caller that supplied exact ownership. */
|
|
66
|
+
get(input: VaultIdentity, _options?: VaultOptions): Promise<{
|
|
67
|
+
summary: VaultSummary;
|
|
68
|
+
payload: VaultMeta | null;
|
|
69
|
+
} | null>;
|
|
70
|
+
/** Singleton runtime read using one provider-independent lookup. Full expected ownership and
|
|
71
|
+
* binding are required before policy/decryption; repo callers may use a broader selection scope.
|
|
72
|
+
* @example await vault.findOne({ workspaceId, kind: "site-assistant", ownerKey: "assistant", principalId: null, binding: null });
|
|
73
|
+
*/
|
|
74
|
+
findOne(input: VaultContext, _options?: VaultOptions): Promise<{
|
|
75
|
+
summary: VaultSummary;
|
|
76
|
+
payload: VaultMeta | null;
|
|
77
|
+
} | null>;
|
|
78
|
+
/** Authorized default read using one exact-group query; ownership is checked before opening.
|
|
79
|
+
* @throws On policy, context, metadata or authentication failure.
|
|
80
|
+
* @complexity One indexed row lookup, then O(payload size) decryption.
|
|
81
|
+
*/
|
|
82
|
+
findDefault(input: VaultDefaultQuery & {
|
|
83
|
+
binding: string | null;
|
|
84
|
+
}, _options?: VaultOptions): Promise<{
|
|
85
|
+
summary: VaultSummary;
|
|
86
|
+
payload: VaultMeta | null;
|
|
87
|
+
} | null>;
|
|
88
|
+
/** Copy selected rows into this service without exposing plaintext or envelopes. Each ID is
|
|
89
|
+
* attempted once; failures are isolated and raw adapter/policy/crypto errors never escape.
|
|
90
|
+
* Source reads are plain SELECTs; this operation never upgrades or writes the source.
|
|
91
|
+
* @throws VaultError for invalid scopes. Per-row failures use fixed reason codes.
|
|
92
|
+
* @complexity O(n + payload bytes), n unique IDs; sequential bounded row reads and CAS writes.
|
|
93
|
+
*/
|
|
94
|
+
copyToSite(input: VaultCopyInput, _options?: VaultOptions): Promise<VaultCopyResult[]>;
|
|
95
|
+
/** One transfer reuses both services' parsing/open/seal and the repository's default/CAS owner.
|
|
96
|
+
* @complexity Constant row queries plus O(payload size); no group read or source transaction.
|
|
97
|
+
*/
|
|
98
|
+
private copyRow;
|
|
99
|
+
/** Human-managed scoped inventory. No envelope or plaintext is opened or returned. */
|
|
100
|
+
list(input: VaultQuery, _options?: VaultOptions): Promise<VaultSummary[]>;
|
|
101
|
+
/** Merge and validate complete metadata, then target only changed JSON keys with revision CAS. */
|
|
102
|
+
patchMeta(input: VaultIdentity & {
|
|
103
|
+
expectedRevision: number;
|
|
104
|
+
patch: VaultMeta;
|
|
105
|
+
}, _options?: VaultOptions): Promise<VaultSummary>;
|
|
106
|
+
/** Re-seal using the active key; concurrent replacement wins through CAS. Metadata-only rows persist. */
|
|
107
|
+
rotate(input: VaultIdentity & {
|
|
108
|
+
expectedRevision: number;
|
|
109
|
+
}, _options?: VaultOptions): Promise<VaultSummary>;
|
|
110
|
+
/** Idempotent for missing rows; existing rows require matching context and revision. */
|
|
111
|
+
delete(input: VaultIdentity & {
|
|
112
|
+
expectedRevision: number;
|
|
113
|
+
}, _options?: VaultOptions): Promise<boolean>;
|
|
114
|
+
}
|
|
115
|
+
//# sourceMappingURL=vault.service.d.ts.map
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { credentialTokenHint } from "../credential-token.js";
|
|
2
|
+
import { buildVaultAad } from "./vault.aad.js";
|
|
3
|
+
import { isCopyableKind } from "./vault.meta.js";
|
|
4
|
+
import { VAULT_KINDS, VaultError } from "./vault.types.js";
|
|
5
|
+
function assertContext(context) {
|
|
6
|
+
if (typeof context.workspaceId !== "string" || !context.workspaceId || typeof context.ownerKey !== "string" || !context.ownerKey ||
|
|
7
|
+
!VAULT_KINDS.includes(context.kind) || (context.principalId !== null && typeof context.principalId !== "string") ||
|
|
8
|
+
("binding" in context && context.binding !== null && typeof context.binding !== "string") ||
|
|
9
|
+
(context.kind === "personal-execution" && !context.principalId))
|
|
10
|
+
throw new VaultError({ code: "context" });
|
|
11
|
+
}
|
|
12
|
+
function summary(record) {
|
|
13
|
+
// Enumerate the read model: an adapter's extra runtime properties cannot become API fields.
|
|
14
|
+
return { workspaceId: record.workspaceId, id: record.id, kind: record.kind, ownerKey: record.ownerKey,
|
|
15
|
+
principalId: record.principalId, binding: record.binding, provider: record.provider, label: record.label,
|
|
16
|
+
isDefault: record.isDefault, revision: record.revision, tokenLength: record.tokenLength,
|
|
17
|
+
last4: record.tokenLength !== null && record.tokenLength >= 12 ? record.last4?.slice(-4) ?? null : null,
|
|
18
|
+
meta: record.meta, createdAt: record.createdAt, updatedAt: record.updatedAt, configured: record.sealed !== null };
|
|
19
|
+
}
|
|
20
|
+
/** Shared vault lifecycle. Policy/catalogs are injected; persistence and encryption are ports.
|
|
21
|
+
* Expected context is checked before opening. Normal reads have no legacy-AAD fallback.
|
|
22
|
+
* Metadata reads never need keys, and every merged patch is parsed before CAS persistence.
|
|
23
|
+
*/
|
|
24
|
+
export class SecretsVaultService {
|
|
25
|
+
deps;
|
|
26
|
+
constructor(deps, _options = {}) {
|
|
27
|
+
this.deps = deps;
|
|
28
|
+
}
|
|
29
|
+
authorize(input) {
|
|
30
|
+
const context = input.context;
|
|
31
|
+
// Policy adapters receive only scope, never a put payload or a metadata patch through an
|
|
32
|
+
// extra-property alias. This also keeps ordinary authorization diagnostics secret-free.
|
|
33
|
+
return this.deps.authorize({ action: input.action, context: { workspaceId: context.workspaceId,
|
|
34
|
+
kind: context.kind, ownerKey: context.ownerKey, principalId: context.principalId,
|
|
35
|
+
...("binding" in context ? { binding: context.binding } : {}),
|
|
36
|
+
...("id" in context ? { id: context.id } : {}),
|
|
37
|
+
...("provider" in context && context.provider !== undefined ? { provider: context.provider } : {}) } });
|
|
38
|
+
}
|
|
39
|
+
parse(record) {
|
|
40
|
+
assertContext(record);
|
|
41
|
+
return { ...record, meta: this.deps.metaParsers[record.kind]({ meta: record.meta }) };
|
|
42
|
+
}
|
|
43
|
+
async find(input) {
|
|
44
|
+
const record = await this.deps.repo.find({ workspaceId: input.workspaceId, id: input.id });
|
|
45
|
+
if (!record)
|
|
46
|
+
return null;
|
|
47
|
+
if (buildVaultAad(input) !== buildVaultAad(record))
|
|
48
|
+
throw new VaultError({ code: "context" });
|
|
49
|
+
return this.parse(record);
|
|
50
|
+
}
|
|
51
|
+
async seal(input) {
|
|
52
|
+
if (input.payload === null)
|
|
53
|
+
return null;
|
|
54
|
+
// JSON round-trip makes the payload contract explicit; encryption never receives an object
|
|
55
|
+
// coerced to '[object Object]' or a connection URI masquerading as an entire payload.
|
|
56
|
+
if (typeof input.payload !== "object" || Array.isArray(input.payload))
|
|
57
|
+
throw new VaultError({ code: "invalid-record" });
|
|
58
|
+
const plaintext = JSON.stringify(input.payload);
|
|
59
|
+
return this.deps.sealer.seal({ plaintext, key: await this.deps.keyring.activeKey({}), aad: buildVaultAad(input.context) });
|
|
60
|
+
}
|
|
61
|
+
async open(record) {
|
|
62
|
+
if (!record.sealed)
|
|
63
|
+
return null;
|
|
64
|
+
const plaintext = await this.deps.sealer.open({ sealed: record.sealed }, { aad: buildVaultAad(record) });
|
|
65
|
+
let payload;
|
|
66
|
+
try {
|
|
67
|
+
payload = JSON.parse(plaintext);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
throw new VaultError({ code: "invalid-record" });
|
|
71
|
+
}
|
|
72
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload))
|
|
73
|
+
throw new VaultError({ code: "invalid-record" });
|
|
74
|
+
return payload;
|
|
75
|
+
}
|
|
76
|
+
/** Create/replace configuration and a sealed JSON payload. Throws on policy, validation or CAS failure. */
|
|
77
|
+
async put(input, _options = {}) {
|
|
78
|
+
const record = await this.prepare(input);
|
|
79
|
+
return summary(this.parse(await this.deps.repo.put({ record, expectedRevision: input.expectedRevision })));
|
|
80
|
+
}
|
|
81
|
+
/** Authorize, validate and seal before the host enters its transaction, without persisting.
|
|
82
|
+
* Replacements retain identity/createdAt; omitted payload preserves the existing envelope.
|
|
83
|
+
* Commit must CAS again because preparation does not reserve a revision or a default.
|
|
84
|
+
* Prepared records contain envelopes and stay private to the host, never a public read model.
|
|
85
|
+
* @example const record = await vault.prepare(input); await repo.put({ record, expectedRevision: input.expectedRevision }); // inside the host transaction
|
|
86
|
+
*/
|
|
87
|
+
async prepare(input, _options = {}) {
|
|
88
|
+
assertContext(input);
|
|
89
|
+
await this.authorize({ action: "put", context: input });
|
|
90
|
+
if (input.expectedRevision !== null && !input.id)
|
|
91
|
+
throw new VaultError({ code: "conflict" });
|
|
92
|
+
const identity = { workspaceId: input.workspaceId, kind: input.kind, ownerKey: input.ownerKey,
|
|
93
|
+
principalId: input.principalId, binding: input.binding, id: input.id ?? this.deps.newId({}) };
|
|
94
|
+
const existing = await this.find(identity);
|
|
95
|
+
if (input.expectedRevision === null ? existing !== null : existing?.revision !== input.expectedRevision)
|
|
96
|
+
throw new VaultError({ code: "conflict" });
|
|
97
|
+
const meta = this.deps.metaParsers[input.kind]({ meta: input.meta });
|
|
98
|
+
if (input.payload === undefined && !existing)
|
|
99
|
+
throw new VaultError({ code: "invalid-record" });
|
|
100
|
+
const sealed = input.payload === undefined ? existing.sealed : await this.seal({ context: identity, payload: input.payload });
|
|
101
|
+
// Never expose any suffix of a database address, environment map or OAuth session.
|
|
102
|
+
const hint = sealed && input.hintToken !== undefined && !["database-destination", "mcp-env", "mcp-auth"].includes(input.kind)
|
|
103
|
+
? credentialTokenHint({ token: input.hintToken }) : null;
|
|
104
|
+
const now = this.deps.now({});
|
|
105
|
+
const record = { ...identity, provider: input.provider, label: input.label, meta, sealed,
|
|
106
|
+
isDefault: input.isDefault ?? (existing !== null && existing.provider === input.provider && existing.isDefault),
|
|
107
|
+
revision: (input.expectedRevision ?? 0) + 1,
|
|
108
|
+
last4: input.payload === undefined ? existing.last4 : hint?.last4 ?? null,
|
|
109
|
+
tokenLength: input.payload === undefined ? existing.tokenLength : hint?.length ?? null,
|
|
110
|
+
createdAt: existing?.createdAt ?? now, updatedAt: now };
|
|
111
|
+
return record;
|
|
112
|
+
}
|
|
113
|
+
/** Authorized runtime read; returns plaintext only to the caller that supplied exact ownership. */
|
|
114
|
+
async get(input, _options = {}) {
|
|
115
|
+
assertContext(input);
|
|
116
|
+
await this.authorize({ action: "get", context: input });
|
|
117
|
+
const record = await this.find(input);
|
|
118
|
+
return record ? { summary: summary(record), payload: await this.open(record) } : null;
|
|
119
|
+
}
|
|
120
|
+
/** Singleton runtime read using one provider-independent lookup. Full expected ownership and
|
|
121
|
+
* binding are required before policy/decryption; repo callers may use a broader selection scope.
|
|
122
|
+
* @example await vault.findOne({ workspaceId, kind: "site-assistant", ownerKey: "assistant", principalId: null, binding: null });
|
|
123
|
+
*/
|
|
124
|
+
async findOne(input, _options = {}) {
|
|
125
|
+
assertContext(input);
|
|
126
|
+
await this.authorize({ action: "get", context: input });
|
|
127
|
+
if (!this.deps.repo.findOne)
|
|
128
|
+
throw new VaultError({ code: "invalid-record" });
|
|
129
|
+
const record = await this.deps.repo.findOne(input);
|
|
130
|
+
if (!record)
|
|
131
|
+
return null;
|
|
132
|
+
if (buildVaultAad({ ...input, id: record.id }) !== buildVaultAad(record))
|
|
133
|
+
throw new VaultError({ code: "context" });
|
|
134
|
+
const parsed = this.parse(record);
|
|
135
|
+
return { summary: summary(parsed), payload: await this.open(parsed) };
|
|
136
|
+
}
|
|
137
|
+
/** Authorized default read using one exact-group query; ownership is checked before opening.
|
|
138
|
+
* @throws On policy, context, metadata or authentication failure.
|
|
139
|
+
* @complexity One indexed row lookup, then O(payload size) decryption.
|
|
140
|
+
*/
|
|
141
|
+
async findDefault(input, _options = {}) {
|
|
142
|
+
assertContext(input);
|
|
143
|
+
await this.authorize({ action: "get", context: input });
|
|
144
|
+
// Old injected ports retain their existing operations; default reads require this capability.
|
|
145
|
+
if (!this.deps.repo.findDefault)
|
|
146
|
+
throw new VaultError({ code: "invalid-record" });
|
|
147
|
+
const record = await this.deps.repo.findDefault(input);
|
|
148
|
+
if (!record)
|
|
149
|
+
return null;
|
|
150
|
+
if (!record.isDefault || record.provider !== input.provider || buildVaultAad({ ...input, id: record.id }) !== buildVaultAad(record)) {
|
|
151
|
+
throw new VaultError({ code: "context" });
|
|
152
|
+
}
|
|
153
|
+
const parsed = this.parse(record);
|
|
154
|
+
return { summary: summary(parsed), payload: await this.open(parsed) };
|
|
155
|
+
}
|
|
156
|
+
/** Copy selected rows into this service without exposing plaintext or envelopes. Each ID is
|
|
157
|
+
* attempted once; failures are isolated and raw adapter/policy/crypto errors never escape.
|
|
158
|
+
* Source reads are plain SELECTs; this operation never upgrades or writes the source.
|
|
159
|
+
* @throws VaultError for invalid scopes. Per-row failures use fixed reason codes.
|
|
160
|
+
* @complexity O(n + payload bytes), n unique IDs; sequential bounded row reads and CAS writes.
|
|
161
|
+
*/
|
|
162
|
+
async copyToSite(input, _options = {}) {
|
|
163
|
+
assertContext(input.sourceContext);
|
|
164
|
+
assertContext(input.targetContext);
|
|
165
|
+
const results = [];
|
|
166
|
+
for (const id of new Set(input.ids))
|
|
167
|
+
results.push(await this.copyRow({ ...input, id }));
|
|
168
|
+
return results;
|
|
169
|
+
}
|
|
170
|
+
/** One transfer reuses both services' parsing/open/seal and the repository's default/CAS owner.
|
|
171
|
+
* @complexity Constant row queries plus O(payload size); no group read or source transaction.
|
|
172
|
+
*/
|
|
173
|
+
async copyRow(input, _options = {}) {
|
|
174
|
+
const { source, sourceContext, targetContext, id } = input;
|
|
175
|
+
const result = { kind: sourceContext.kind, id };
|
|
176
|
+
if (!isCopyableKind[sourceContext.kind])
|
|
177
|
+
return { ...result, status: "skipped",
|
|
178
|
+
reasonCode: sourceContext.kind === "mcp-env" ? "requires-mcp-configuration" : "kind-not-copyable" };
|
|
179
|
+
try {
|
|
180
|
+
// Copy changes the site/key, never the credential's owning feature or endpoint. Principal
|
|
181
|
+
// identities are local to each installation and cannot be carried to the target.
|
|
182
|
+
if (sourceContext.kind !== targetContext.kind || sourceContext.ownerKey !== targetContext.ownerKey ||
|
|
183
|
+
sourceContext.binding !== targetContext.binding || sourceContext.principalId !== null || targetContext.principalId !== null ||
|
|
184
|
+
(source.deps.repo === this.deps.repo && sourceContext.workspaceId === targetContext.workspaceId)) {
|
|
185
|
+
throw new VaultError({ code: "context" });
|
|
186
|
+
}
|
|
187
|
+
const sourceIdentity = { ...sourceContext, id };
|
|
188
|
+
const targetIdentity = { ...targetContext, id };
|
|
189
|
+
await source.authorize({ action: "get", context: sourceIdentity });
|
|
190
|
+
await this.authorize({ action: "put", context: targetIdentity });
|
|
191
|
+
const raw = await source.deps.repo.find(sourceIdentity);
|
|
192
|
+
if (!raw)
|
|
193
|
+
return { ...result, status: "failed", reasonCode: "source-missing" };
|
|
194
|
+
if (buildVaultAad(sourceIdentity) !== buildVaultAad(raw))
|
|
195
|
+
throw new VaultError({ code: "context" });
|
|
196
|
+
// Check before parsing: normal parsers deliberately reject legacy-AAD metadata.
|
|
197
|
+
if (Object.hasOwn(raw.meta, "legacyAad"))
|
|
198
|
+
return { ...result, status: "failed", reasonCode: "source-needs-upgrade" };
|
|
199
|
+
if (input.sourceRevisions && Object.hasOwn(input.sourceRevisions, id) && input.sourceRevisions[id] !== raw.revision)
|
|
200
|
+
throw new VaultError({ code: "conflict" });
|
|
201
|
+
const record = source.parse(raw);
|
|
202
|
+
const existing = await this.find(targetIdentity);
|
|
203
|
+
if (existing && !input.overwrite)
|
|
204
|
+
return { ...result, status: "skipped", reasonCode: "target-exists" };
|
|
205
|
+
if (existing && existing.provider !== record.provider)
|
|
206
|
+
throw new VaultError({ code: "context" });
|
|
207
|
+
const expectedRevision = existing?.revision ?? null;
|
|
208
|
+
const now = this.deps.now({});
|
|
209
|
+
const candidate = this.parse({ ...record, ...targetIdentity, isDefault: existing?.isDefault ?? false,
|
|
210
|
+
revision: (expectedRevision ?? 0) + 1, createdAt: existing?.createdAt ?? now, updatedAt: now });
|
|
211
|
+
candidate.sealed = await this.seal({ context: targetIdentity, payload: await source.open(record) });
|
|
212
|
+
// Reuse the source's hint policy without guessing a token field from a structured payload.
|
|
213
|
+
candidate.last4 = record.kind === "database-destination" ? null : summary(record).last4;
|
|
214
|
+
candidate.tokenLength = record.kind === "database-destination" ? null : record.tokenLength;
|
|
215
|
+
const saved = await this.deps.repo.put({ record: candidate, expectedRevision });
|
|
216
|
+
const last4 = summary(saved).last4;
|
|
217
|
+
return { ...result, status: "copied", ...(last4 ? { last4 } : {}) };
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
return { ...result, status: "failed", reasonCode: error instanceof VaultError ? error.code : "copy-failed" };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/** Human-managed scoped inventory. No envelope or plaintext is opened or returned. */
|
|
224
|
+
async list(input, _options = {}) {
|
|
225
|
+
assertContext(input);
|
|
226
|
+
await this.authorize({ action: "list", context: input });
|
|
227
|
+
return (await this.deps.repo.list(input)).map(record => {
|
|
228
|
+
if (record.workspaceId !== input.workspaceId || record.kind !== input.kind || record.ownerKey !== input.ownerKey ||
|
|
229
|
+
record.principalId !== input.principalId || (input.provider !== undefined && record.provider !== input.provider))
|
|
230
|
+
throw new VaultError({ code: "context" });
|
|
231
|
+
return summary(this.parse(record));
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
/** Merge and validate complete metadata, then target only changed JSON keys with revision CAS. */
|
|
235
|
+
async patchMeta(input, _options = {}) {
|
|
236
|
+
assertContext(input);
|
|
237
|
+
await this.authorize({ action: "put", context: input });
|
|
238
|
+
const existing = await this.find(input);
|
|
239
|
+
if (existing?.revision !== input.expectedRevision)
|
|
240
|
+
throw new VaultError({ code: "conflict" });
|
|
241
|
+
const meta = this.deps.metaParsers[existing.kind]({ meta: { ...existing.meta, ...input.patch } });
|
|
242
|
+
// jsonSet can add/replace a key but cannot remove one. Reject parser omissions instead of
|
|
243
|
+
// silently persisting an unvalidated old field. Normalized values and defaults can be patched.
|
|
244
|
+
if (Object.keys({ ...existing.meta, ...input.patch }).some(key => !Object.hasOwn(meta, key)))
|
|
245
|
+
throw new VaultError({ code: "invalid-meta" });
|
|
246
|
+
const patch = Object.fromEntries(Object.entries(meta).filter(([key, value]) => JSON.stringify(value) !== JSON.stringify(existing.meta[key])));
|
|
247
|
+
return summary(this.parse(await this.deps.repo.patchMeta({ workspaceId: input.workspaceId, id: input.id,
|
|
248
|
+
expectedRevision: input.expectedRevision, patch, updatedAt: this.deps.now({}) })));
|
|
249
|
+
}
|
|
250
|
+
/** Re-seal using the active key; concurrent replacement wins through CAS. Metadata-only rows persist. */
|
|
251
|
+
async rotate(input, _options = {}) {
|
|
252
|
+
assertContext(input);
|
|
253
|
+
await this.authorize({ action: "rotate", context: input });
|
|
254
|
+
const existing = await this.find(input);
|
|
255
|
+
if (existing?.revision !== input.expectedRevision)
|
|
256
|
+
throw new VaultError({ code: "conflict" });
|
|
257
|
+
const sealed = await this.seal({ context: input, payload: await this.open(existing) });
|
|
258
|
+
return summary(this.parse(await this.deps.repo.put({ expectedRevision: input.expectedRevision,
|
|
259
|
+
record: { ...existing, sealed, revision: existing.revision + 1, updatedAt: this.deps.now({}) } })));
|
|
260
|
+
}
|
|
261
|
+
/** Idempotent for missing rows; existing rows require matching context and revision. */
|
|
262
|
+
async delete(input, _options = {}) {
|
|
263
|
+
assertContext(input);
|
|
264
|
+
await this.authorize({ action: "delete", context: input });
|
|
265
|
+
const existing = await this.find(input);
|
|
266
|
+
if (!existing)
|
|
267
|
+
return false;
|
|
268
|
+
if (existing.revision !== input.expectedRevision)
|
|
269
|
+
throw new VaultError({ code: "conflict" });
|
|
270
|
+
return this.deps.repo.delete({ workspaceId: input.workspaceId, id: input.id, expectedRevision: input.expectedRevision });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
//# sourceMappingURL=vault.service.js.map
|