@jini-ai/platform 0.5.9 → 0.5.10
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 +7 -0
- package/dist/asset-cache.js +26 -25
- package/dist/blob-storage.d.ts +7 -2
- package/dist/blob-storage.js +99 -59
- package/dist/child-environment.d.ts +36 -0
- package/dist/child-environment.js +68 -0
- package/dist/download.js +192 -148
- package/dist/fs/atomic-write.js +105 -93
- package/dist/fs/boot-identity.d.ts +16 -0
- package/dist/fs/boot-identity.js +38 -17
- package/dist/fs/directory-home.js +6 -0
- package/dist/fs/directory-registry.d.ts +1 -1
- package/dist/fs/directory-registry.js +23 -16
- package/dist/fs/directory-running-lock.d.ts +1 -1
- package/dist/fs/directory-running-lock.js +18 -7
- package/dist/fs/directory-sync.d.ts +1 -1
- package/dist/fs/directory-sync.js +64 -51
- package/dist/fs/directory-user-config.js +108 -67
- package/dist/fs/directory-workspace.d.ts +11 -0
- package/dist/fs/directory-workspace.js +16 -11
- package/dist/fs/durable-json.js +49 -37
- package/dist/fs/file-lock.js +214 -149
- package/dist/fs/guarded-reader.js +47 -35
- package/dist/fs/user-data-directories.d.ts +1 -1
- package/dist/fs/user-data-directories.js +25 -9
- package/dist/fs/verified-directory-move.d.ts +13 -11
- package/dist/fs/verified-directory-move.js +105 -68
- package/dist/fs/verified-file-relocation.d.ts +1 -1
- package/dist/fs/verified-file-relocation.js +111 -94
- package/dist/http/guarded/client.d.ts +3 -4
- package/dist/http/guarded/client.js +50 -60
- package/dist/http/guarded/fetch-adapter.d.ts +2 -3
- package/dist/http/guarded/fetch-adapter.js +26 -15
- package/dist/mail/builtin-providers.d.ts +39 -0
- package/dist/mail/builtin-providers.js +60 -0
- package/dist/mail/feedback.d.ts +84 -0
- package/dist/mail/feedback.js +90 -0
- package/dist/mail/index.d.ts +11 -0
- package/dist/mail/index.js +6 -0
- package/dist/mail/policy.d.ts +77 -0
- package/dist/mail/policy.js +155 -0
- package/dist/mail/ports.d.ts +56 -7
- package/dist/mail/provider.d.ts +117 -0
- package/dist/mail/provider.js +74 -0
- package/dist/mail/rate-limit.d.ts +33 -0
- package/dist/mail/rate-limit.js +35 -0
- package/dist/mail/sender-identity.d.ts +68 -0
- package/dist/mail/sender-identity.js +50 -0
- package/dist/mail/types.d.ts +9 -0
- package/dist/net/address.d.ts +9 -23
- package/dist/net/address.js +28 -133
- package/dist/net/endpoint-policy.d.ts +26 -2
- package/dist/net/endpoint-policy.js +159 -56
- package/dist/object-store/index.d.ts +2 -0
- package/dist/object-store/index.js +2 -0
- package/dist/object-store/local-fs.d.ts +17 -0
- package/dist/object-store/local-fs.js +146 -0
- package/dist/process.js +2 -0
- package/dist/proxy-env.js +131 -135
- package/dist/secrets/credential-sets/contracts.d.ts +1 -0
- package/dist/secrets/credential-sets/contracts.js +1 -0
- package/dist/secrets/credential-sets/index.d.ts +1 -0
- package/dist/secrets/credential-sets/index.js +1 -0
- package/dist/secrets/credential-sets/provider-matches.d.ts +61 -0
- package/dist/secrets/credential-sets/provider-matches.js +61 -0
- package/dist/secrets/credential-sets/types.d.ts +7 -0
- package/dist/secrets/credential-sets/vault.meta.js +58 -20
- package/dist/secrets/credential-sets/vault.repo.memory.d.ts +7 -0
- package/dist/secrets/credential-sets/vault.repo.memory.js +43 -36
- package/dist/secrets/credential-sets/vault.service.d.ts +9 -0
- package/dist/secrets/credential-sets/vault.service.js +131 -61
- package/dist/secrets/credential-sets/vault.types.d.ts +11 -3
- package/dist/secrets/credential-sets/vault.types.js +22 -8
- package/dist/secrets/keyring.env.js +14 -10
- package/dist/toolchain.js +119 -91
- package/dist/web/html-document.js +73 -69
- package/dist/web/html-to-markdown.js +127 -102
- package/dist/web/web-page.js +160 -80
- package/package.json +11 -3
|
@@ -8,6 +8,10 @@ export interface VendorCredentialSetRecord {
|
|
|
8
8
|
readonly id: string;
|
|
9
9
|
/** Open string so plugin hosts can declare vendors beyond the built-in validators. */
|
|
10
10
|
readonly vendorId: string;
|
|
11
|
+
/** Optional only for historical DTO producers; unified vault metadata requires this subtype. */
|
|
12
|
+
readonly credentialType?: "vendor";
|
|
13
|
+
/** Explicit compatibility IDs, independent of label; omitted by historical DTO producers. */
|
|
14
|
+
readonly worksFor?: readonly string[];
|
|
11
15
|
readonly label: string;
|
|
12
16
|
readonly sealed: SealedSecret;
|
|
13
17
|
/** Last four secret characters for recognition, stored in clear; a suffix alone is not the token. */
|
|
@@ -22,6 +26,9 @@ export interface VendorCredentialSetRecord {
|
|
|
22
26
|
export interface VendorCredentialSetSummary {
|
|
23
27
|
readonly id: string;
|
|
24
28
|
readonly vendorId: string;
|
|
29
|
+
/** Optional for compatibility with historical summaries. */
|
|
30
|
+
readonly credentialType?: "vendor";
|
|
31
|
+
readonly worksFor?: readonly string[];
|
|
25
32
|
readonly label: string;
|
|
26
33
|
readonly configured: true;
|
|
27
34
|
readonly isDefault: boolean;
|
|
@@ -1,19 +1,25 @@
|
|
|
1
1
|
import { VAULT_KINDS, VaultError } from "./vault.types.js";
|
|
2
|
+
import { normalizeCredentialWorksFor } from "./provider-matches.js";
|
|
2
3
|
/** Copy eligibility lives with the kind contracts; MCP rows require their server configuration. */
|
|
3
4
|
export const isCopyableKind = Object.freeze({
|
|
4
|
-
"site-assistant": true, "personal-execution": false, "media-provider": true, custom:
|
|
5
|
+
"site-assistant": true, "personal-execution": false, "media-provider": true, custom: false,
|
|
5
6
|
"publish-peer": true, "database-destination": true, vendor: true, "source-control": true,
|
|
6
7
|
"mcp-env": false, "mcp-auth": false,
|
|
8
|
+
// A plugin API connection's tokens and attempts are bound to one workspace's sign-in.
|
|
9
|
+
"api-connection": false,
|
|
7
10
|
});
|
|
8
11
|
const FIELDS = {
|
|
9
12
|
"site-assistant": { baseUrl: "string", model: "string" },
|
|
10
13
|
"personal-execution": { protocol: "string", baseUrl: "string", model: "string", maxTokens: "number" },
|
|
11
14
|
"media-provider": { baseUrl: "string", model: "string" },
|
|
12
|
-
custom: { category: "string", baseUrl: "string", additionalHosts: "strings", username: "string" },
|
|
15
|
+
custom: { credentialType: "string", worksFor: "strings", category: "string", baseUrl: "string", additionalHosts: "strings", username: "string" },
|
|
13
16
|
"publish-peer": { baseUrl: "string", remoteWorkspaceId: "string" },
|
|
14
17
|
"database-destination": { description: "object", lastRun: "object" },
|
|
15
|
-
vendor: { accountLabel: "string"
|
|
18
|
+
vendor: { accountLabel: "string", credentialType: "string", worksFor: "strings", category: "string",
|
|
19
|
+
baseUrl: "string", additionalHosts: "strings", username: "string" }, "source-control": { accountLabel: "string" },
|
|
16
20
|
"mcp-env": {}, "mcp-auth": {},
|
|
21
|
+
// Hosts register the owning feature's parser (e.g. parseOAuthTokenVaultMeta); no default fields.
|
|
22
|
+
"api-connection": {},
|
|
17
23
|
};
|
|
18
24
|
function isObject(value) {
|
|
19
25
|
return value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype;
|
|
@@ -27,26 +33,58 @@ function isJson(value) {
|
|
|
27
33
|
return value.every(isJson);
|
|
28
34
|
return isObject(value) && Object.keys(value).every(key => !["__proto__", "constructor", "prototype"].includes(key) && isJson(value[key]));
|
|
29
35
|
}
|
|
36
|
+
/** Enforce common credential metadata before and after optional host policy parsing.
|
|
37
|
+
* Missing fields on pre-unification rows are read defaults, never a reason to rewrite/reseal.
|
|
38
|
+
* Explicit empty compatibility is preserved; labels never imply compatibility.
|
|
39
|
+
* @throws VaultError when a supplied subtype or compatibility list is invalid.
|
|
40
|
+
* @complexity O(worksFor characters); returns a fresh metadata object.
|
|
41
|
+
*/
|
|
42
|
+
function credentialMeta({ meta, kind, provider }) {
|
|
43
|
+
const credentialType = meta.credentialType === undefined ? kind : meta.credentialType;
|
|
44
|
+
if ((credentialType !== "vendor" && credentialType !== "custom") || (kind === "custom" && credentialType !== "custom"))
|
|
45
|
+
throw new VaultError({ code: "invalid-meta" });
|
|
46
|
+
const worksFor = meta.worksFor === undefined ? (credentialType === "vendor" && provider != null ? [provider] : []) : meta.worksFor;
|
|
47
|
+
return { ...meta, credentialType, worksFor: normalizeCredentialWorksFor({ value: worksFor }) };
|
|
48
|
+
}
|
|
49
|
+
/** Plain JSON object with identifier-only top-level keys; checked on input and on override output. */
|
|
50
|
+
function requireMetaShape({ value }) {
|
|
51
|
+
if (!isObject(value) || !isJson(value) || Object.keys(value).some(key => !/^[A-Za-z0-9_]+$/.test(key)))
|
|
52
|
+
throw new VaultError({ code: "invalid-meta" });
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
/** vendor/custom rows carry the unified subtype + compatibility contract; other kinds pass through. */
|
|
56
|
+
function canonicalMeta({ meta, kind, provider }) {
|
|
57
|
+
return kind === "vendor" || kind === "custom" ? credentialMeta({ meta, kind, provider }) : meta;
|
|
58
|
+
}
|
|
59
|
+
function matchesField({ field, value }) {
|
|
60
|
+
if (value === null)
|
|
61
|
+
return true;
|
|
62
|
+
if (field === "strings")
|
|
63
|
+
return Array.isArray(value) && value.every(v => typeof v === "string");
|
|
64
|
+
if (field === "object")
|
|
65
|
+
return isObject(value);
|
|
66
|
+
return typeof value === field;
|
|
67
|
+
}
|
|
68
|
+
function assertDeclaredFields({ meta, kind }) {
|
|
69
|
+
for (const [key, value] of Object.entries(meta)) {
|
|
70
|
+
const field = Object.hasOwn(FIELDS[kind], key) ? FIELDS[kind][key] : undefined;
|
|
71
|
+
if (!field || !matchesField({ field, value }))
|
|
72
|
+
throw new VaultError({ code: "invalid-meta" });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function parseKindMeta({ kind, override }, { meta, provider }) {
|
|
76
|
+
const canonical = canonicalMeta({ meta: requireMetaShape({ value: meta }), kind, provider });
|
|
77
|
+
if (override) {
|
|
78
|
+
const parsed = requireMetaShape({ value: override({ meta: structuredClone(canonical), provider }) });
|
|
79
|
+
return structuredClone(canonicalMeta({ meta: parsed, kind, provider }));
|
|
80
|
+
}
|
|
81
|
+
assertDeclaredFields({ meta: canonical, kind });
|
|
82
|
+
return structuredClone(canonical);
|
|
83
|
+
}
|
|
30
84
|
/** Minimal structural parsers. Hosts inject catalog, URL/origin and safe-metadata policy here;
|
|
31
85
|
* secrets stay in payload JSON. Overrides own the complete shape, including merged patches.
|
|
32
86
|
*/
|
|
33
87
|
export function createVaultMetaParsers(_required, { parsers = {} } = {}) {
|
|
34
|
-
return Object.fromEntries(VAULT_KINDS.map(kind => [kind, ({
|
|
35
|
-
if (!isObject(meta) || !isJson(meta) || Object.keys(meta).some(key => !/^[A-Za-z0-9_]+$/.test(key)))
|
|
36
|
-
throw new VaultError({ code: "invalid-meta" });
|
|
37
|
-
if (parsers[kind]) {
|
|
38
|
-
const parsed = parsers[kind]({ meta: structuredClone(meta) });
|
|
39
|
-
if (!isObject(parsed) || !isJson(parsed) || Object.keys(parsed).some(key => !/^[A-Za-z0-9_]+$/.test(key)))
|
|
40
|
-
throw new VaultError({ code: "invalid-meta" });
|
|
41
|
-
return structuredClone(parsed);
|
|
42
|
-
}
|
|
43
|
-
for (const [key, value] of Object.entries(meta)) {
|
|
44
|
-
const field = Object.hasOwn(FIELDS[kind], key) ? FIELDS[kind][key] : undefined;
|
|
45
|
-
if (!field || (value !== null && !(field === "strings" ? Array.isArray(value) && value.every(v => typeof v === "string") :
|
|
46
|
-
field === "object" ? isObject(value) : typeof value === field)))
|
|
47
|
-
throw new VaultError({ code: "invalid-meta" });
|
|
48
|
-
}
|
|
49
|
-
return structuredClone(meta);
|
|
50
|
-
}]));
|
|
88
|
+
return Object.fromEntries([...VAULT_KINDS, "custom"].map(kind => [kind, (input) => parseKindMeta({ kind, override: parsers[kind] }, input)]));
|
|
51
89
|
}
|
|
52
90
|
//# sourceMappingURL=vault.meta.js.map
|
|
@@ -12,6 +12,7 @@ export declare class InMemoryVaultRepo implements VaultRepoPort {
|
|
|
12
12
|
private singletonKey;
|
|
13
13
|
/** Index all four optional scope combinations; copy sets so batch staging never mutates live indexes. */
|
|
14
14
|
private indexSingleton;
|
|
15
|
+
private singletonScopes;
|
|
15
16
|
/** Indexed singleton selection; clone only the selected row, ignoring provider/default. */
|
|
16
17
|
findOne(input: VaultSingletonQuery, _options?: VaultOptions): Promise<VaultRecord | null>;
|
|
17
18
|
/** Stage through the same mutation owner, then publish once. No await can expose partial rows
|
|
@@ -47,5 +48,11 @@ export declare class InMemoryVaultRepo implements VaultRepoPort {
|
|
|
47
48
|
private deleteRecord;
|
|
48
49
|
/** O(n) winner scan; promotion changes only default/revision, matching SQL. */
|
|
49
50
|
private promote;
|
|
51
|
+
/** Every default change increments revision. A replaced row may be cleared here too: the caller
|
|
52
|
+
* writes its saved version over that entry immediately afterwards.
|
|
53
|
+
*/
|
|
54
|
+
private clearDefaults;
|
|
55
|
+
/** O(n) scan of live rows sharing one default group. */
|
|
56
|
+
private groupRows;
|
|
50
57
|
}
|
|
51
58
|
//# sourceMappingURL=vault.repo.memory.d.ts.map
|
|
@@ -13,20 +13,22 @@ export class InMemoryVaultRepo {
|
|
|
13
13
|
input.principalId !== undefined, input.principalId ?? null]);
|
|
14
14
|
}
|
|
15
15
|
/** Index all four optional scope combinations; copy sets so batch staging never mutates live indexes. */
|
|
16
|
-
indexSingleton({ record, remove }
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
16
|
+
indexSingleton({ record, remove }) {
|
|
17
|
+
const rowKey = this.key(record);
|
|
18
|
+
for (const scope of this.singletonScopes({ record })) {
|
|
19
|
+
const ids = new Set(this.singletons.get(scope));
|
|
20
|
+
if (remove)
|
|
21
|
+
ids.delete(rowKey);
|
|
22
|
+
else
|
|
23
|
+
ids.add(rowKey);
|
|
24
|
+
if (ids.size)
|
|
25
|
+
this.singletons.set(scope, ids);
|
|
26
|
+
else
|
|
27
|
+
this.singletons.delete(scope);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
singletonScopes({ record }) {
|
|
31
|
+
return [undefined, record.ownerKey].flatMap(ownerKey => [undefined, record.principalId].map(principalId => this.singletonKey({ workspaceId: record.workspaceId, kind: record.kind, ownerKey, principalId })));
|
|
30
32
|
}
|
|
31
33
|
/** Indexed singleton selection; clone only the selected row, ignoring provider/default. */
|
|
32
34
|
async findOne(input, _options = {}) {
|
|
@@ -63,33 +65,26 @@ export class InMemoryVaultRepo {
|
|
|
63
65
|
async list(input, _options = {}) {
|
|
64
66
|
return structuredClone([...this.rows.values()].filter(row => row.workspaceId === input.workspaceId && row.kind === input.kind &&
|
|
65
67
|
row.ownerKey === input.ownerKey && row.principalId === input.principalId && (input.provider === undefined || row.provider === input.provider))
|
|
66
|
-
|
|
68
|
+
// Rows are keyed by (workspaceId, id), so one workspace never lists two equal IDs.
|
|
69
|
+
.sort((a, b) => a.id < b.id ? -1 : 1));
|
|
67
70
|
}
|
|
68
71
|
async put(input, _options = {}) {
|
|
69
72
|
return this.putRecord(input);
|
|
70
73
|
}
|
|
71
74
|
/** Synchronous mutation owner shared with batch staging; default moves never re-seal. */
|
|
72
|
-
putRecord(input
|
|
75
|
+
putRecord(input) {
|
|
73
76
|
const { record } = input;
|
|
74
77
|
const key = this.key(record);
|
|
75
78
|
const existing = this.rows.get(key) ?? null;
|
|
76
79
|
assertVaultReplacement({ ...input, existing });
|
|
77
80
|
const groupKey = vaultGroupKey({ record });
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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 });
|
|
81
|
+
const group = this.groupRows({ groupKey });
|
|
82
|
+
if (group.some(row => row.id !== record.id && row.label === record.label))
|
|
83
|
+
throw new VaultError({ code: "conflict" });
|
|
84
|
+
const saved = structuredClone({ ...record, isDefault: record.isDefault || group.length === 0 });
|
|
86
85
|
// Clear first, matching a SQL partial UNIQUE index; no transient duplicate default is required.
|
|
87
86
|
if (saved.isDefault)
|
|
88
|
-
|
|
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
|
-
}
|
|
87
|
+
this.clearDefaults({ group });
|
|
93
88
|
this.rows.set(key, saved);
|
|
94
89
|
if (!existing)
|
|
95
90
|
this.indexSingleton({ record: saved, remove: false });
|
|
@@ -101,7 +96,7 @@ export class InMemoryVaultRepo {
|
|
|
101
96
|
return this.patchRecord(input);
|
|
102
97
|
}
|
|
103
98
|
/** Clone merged metadata before mutating the staged/live map so failures leave it intact. */
|
|
104
|
-
patchRecord(input
|
|
99
|
+
patchRecord(input) {
|
|
105
100
|
const key = this.key(input);
|
|
106
101
|
const existing = this.rows.get(key);
|
|
107
102
|
if (existing?.revision !== input.expectedRevision)
|
|
@@ -114,7 +109,7 @@ export class InMemoryVaultRepo {
|
|
|
114
109
|
return this.deleteRecord(input);
|
|
115
110
|
}
|
|
116
111
|
/** Synchronous CAS delete shares promotion with provider moves and batch staging. */
|
|
117
|
-
deleteRecord(input
|
|
112
|
+
deleteRecord(input) {
|
|
118
113
|
const key = this.key(input);
|
|
119
114
|
const existing = this.rows.get(key);
|
|
120
115
|
if (!existing)
|
|
@@ -128,13 +123,12 @@ export class InMemoryVaultRepo {
|
|
|
128
123
|
return true;
|
|
129
124
|
}
|
|
130
125
|
/** O(n) winner scan; promotion changes only default/revision, matching SQL. */
|
|
131
|
-
promote({ record }
|
|
132
|
-
const groupKey = vaultGroupKey({ record });
|
|
126
|
+
promote({ record }) {
|
|
133
127
|
let promoted;
|
|
134
128
|
// SQL BINARY/C compares UTF-8 bytes; JS's UTF-16 order differs for supplementary IDs.
|
|
135
|
-
for (const row of this.
|
|
136
|
-
if (
|
|
137
|
-
(row.updatedAt === promoted.updatedAt && Buffer.compare(Buffer.from(row.id), Buffer.from(promoted.id)) > 0))
|
|
129
|
+
for (const row of this.groupRows({ groupKey: vaultGroupKey({ record }) })) {
|
|
130
|
+
if (!promoted || row.updatedAt > promoted.updatedAt ||
|
|
131
|
+
(row.updatedAt === promoted.updatedAt && Buffer.compare(Buffer.from(row.id), Buffer.from(promoted.id)) > 0)) {
|
|
138
132
|
promoted = { id: row.id, updatedAt: row.updatedAt };
|
|
139
133
|
}
|
|
140
134
|
}
|
|
@@ -144,5 +138,18 @@ export class InMemoryVaultRepo {
|
|
|
144
138
|
this.rows.set(promotedKey, { ...row, isDefault: true, revision: row.revision + 1 });
|
|
145
139
|
}
|
|
146
140
|
}
|
|
141
|
+
/** Every default change increments revision. A replaced row may be cleared here too: the caller
|
|
142
|
+
* writes its saved version over that entry immediately afterwards.
|
|
143
|
+
*/
|
|
144
|
+
clearDefaults({ group }) {
|
|
145
|
+
for (const row of group) {
|
|
146
|
+
if (row.isDefault)
|
|
147
|
+
this.rows.set(this.key(row), { ...row, isDefault: false, revision: row.revision + 1 });
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** O(n) scan of live rows sharing one default group. */
|
|
151
|
+
groupRows({ groupKey }) {
|
|
152
|
+
return [...this.rows.values()].filter(row => vaultGroupKey({ record: row }) === groupKey);
|
|
153
|
+
}
|
|
147
154
|
}
|
|
148
155
|
//# sourceMappingURL=vault.repo.memory.js.map
|
|
@@ -62,6 +62,11 @@ export declare class SecretsVaultService {
|
|
|
62
62
|
* @example const record = await vault.prepare(input); await repo.put({ record, expectedRevision: input.expectedRevision }); // inside the host transaction
|
|
63
63
|
*/
|
|
64
64
|
prepare(input: VaultPutInput, _options?: VaultOptions): Promise<VaultRecord>;
|
|
65
|
+
/** Resolve the target identity and apply the service-level CAS precheck. */
|
|
66
|
+
private findForPut;
|
|
67
|
+
private parsePutMeta;
|
|
68
|
+
/** Omitted payload keeps the existing envelope and hints without opening or sealing. */
|
|
69
|
+
private putEnvelope;
|
|
65
70
|
/** Authorized runtime read; returns plaintext only to the caller that supplied exact ownership. */
|
|
66
71
|
get(input: VaultIdentity, _options?: VaultOptions): Promise<{
|
|
67
72
|
summary: VaultSummary;
|
|
@@ -96,6 +101,10 @@ export declare class SecretsVaultService {
|
|
|
96
101
|
* @complexity Constant row queries plus O(payload size); no group read or source transaction.
|
|
97
102
|
*/
|
|
98
103
|
private copyRow;
|
|
104
|
+
/** Guarded read of the source row, then a CAS write of the resealed target row. */
|
|
105
|
+
private transferRow;
|
|
106
|
+
/** Overwrite keeps the target's default, revision chain and creation time. */
|
|
107
|
+
private writeCopy;
|
|
99
108
|
/** Human-managed scoped inventory. No envelope or plaintext is opened or returned. */
|
|
100
109
|
list(input: VaultQuery, _options?: VaultOptions): Promise<VaultSummary[]>;
|
|
101
110
|
/** Merge and validate complete metadata, then target only changed JSON keys with revision CAS. */
|
|
@@ -2,13 +2,54 @@ import { credentialTokenHint } from "../credential-token.js";
|
|
|
2
2
|
import { buildVaultAad } from "./vault.aad.js";
|
|
3
3
|
import { isCopyableKind } from "./vault.meta.js";
|
|
4
4
|
import { VAULT_KINDS, VaultError } from "./vault.types.js";
|
|
5
|
+
/** Kinds whose payloads are addresses, environment maps or OAuth sessions, never single tokens. */
|
|
6
|
+
const HINTLESS_KINDS = ["database-destination", "mcp-env", "mcp-auth"];
|
|
7
|
+
function isNonEmptyString({ value }) {
|
|
8
|
+
return typeof value === "string" && value !== "";
|
|
9
|
+
}
|
|
10
|
+
function isNullableString({ value }) {
|
|
11
|
+
return value === null || typeof value === "string";
|
|
12
|
+
}
|
|
13
|
+
function hasValidOwnership({ context }) {
|
|
14
|
+
return isNonEmptyString({ value: context.workspaceId }) && isNonEmptyString({ value: context.ownerKey }) &&
|
|
15
|
+
isNullableString({ value: context.principalId }) && (!("binding" in context) || isNullableString({ value: context.binding }));
|
|
16
|
+
}
|
|
17
|
+
function hasValidKind({ context }) {
|
|
18
|
+
if (!VAULT_KINDS.some(kind => kind === context.kind) && context.kind !== "custom")
|
|
19
|
+
return false;
|
|
20
|
+
return context.kind !== "personal-execution" || Boolean(context.principalId);
|
|
21
|
+
}
|
|
5
22
|
function assertContext(context) {
|
|
6
|
-
if (
|
|
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))
|
|
23
|
+
if (!hasValidOwnership({ context }) || !hasValidKind({ context }))
|
|
10
24
|
throw new VaultError({ code: "context" });
|
|
11
25
|
}
|
|
26
|
+
/** Unified credential rows declare both subtype and compatibility; read defaults cover older rows. */
|
|
27
|
+
function declaresCredential({ meta }) {
|
|
28
|
+
return meta !== null && typeof meta === "object" && Object.hasOwn(meta, "credentialType") && Object.hasOwn(meta, "worksFor");
|
|
29
|
+
}
|
|
30
|
+
function isInQueryScope({ record, query }) {
|
|
31
|
+
return record.workspaceId === query.workspaceId && record.kind === query.kind && record.ownerKey === query.ownerKey &&
|
|
32
|
+
record.principalId === query.principalId && (query.provider === undefined || record.provider === query.provider);
|
|
33
|
+
}
|
|
34
|
+
/** Copy changes the site/key, never the credential's owning feature or endpoint. Principal
|
|
35
|
+
* identities are local to each installation and cannot be carried to the target.
|
|
36
|
+
*/
|
|
37
|
+
function isCopyTarget({ sourceContext, targetContext, sameRepo }) {
|
|
38
|
+
return sourceContext.kind === targetContext.kind && sourceContext.ownerKey === targetContext.ownerKey &&
|
|
39
|
+
sourceContext.binding === targetContext.binding && sourceContext.principalId === null && targetContext.principalId === null &&
|
|
40
|
+
!(sameRepo && sourceContext.workspaceId === targetContext.workspaceId);
|
|
41
|
+
}
|
|
42
|
+
function sourceNeedsUpgrade({ raw }) {
|
|
43
|
+
return Object.hasOwn(raw.meta, "legacyAad") || (raw.kind === "vendor" && !declaresCredential({ meta: raw.meta }));
|
|
44
|
+
}
|
|
45
|
+
function isStaleSelection({ selected, id, revision }) {
|
|
46
|
+
if (!selected)
|
|
47
|
+
return false;
|
|
48
|
+
return Object.hasOwn(selected, id) && selected[id] !== revision;
|
|
49
|
+
}
|
|
50
|
+
function isCompatibleOverwrite({ existing, record }) {
|
|
51
|
+
return existing.provider === record.provider && (record.kind !== "vendor" || existing.meta.credentialType === record.meta.credentialType);
|
|
52
|
+
}
|
|
12
53
|
function summary(record) {
|
|
13
54
|
// Enumerate the read model: an adapter's extra runtime properties cannot become API fields.
|
|
14
55
|
return { workspaceId: record.workspaceId, id: record.id, kind: record.kind, ownerKey: record.ownerKey,
|
|
@@ -38,7 +79,7 @@ export class SecretsVaultService {
|
|
|
38
79
|
}
|
|
39
80
|
parse(record) {
|
|
40
81
|
assertContext(record);
|
|
41
|
-
return { ...record, meta: this.deps.metaParsers[record.kind]({ meta: record.meta }) };
|
|
82
|
+
return { ...record, meta: this.deps.metaParsers[record.kind]({ meta: record.meta, provider: record.provider }) };
|
|
42
83
|
}
|
|
43
84
|
async find(input) {
|
|
44
85
|
const record = await this.deps.repo.find({ workspaceId: input.workspaceId, id: input.id });
|
|
@@ -86,7 +127,22 @@ export class SecretsVaultService {
|
|
|
86
127
|
*/
|
|
87
128
|
async prepare(input, _options = {}) {
|
|
88
129
|
assertContext(input);
|
|
130
|
+
// Existing custom-kind identities keep their authenticated kind until a migration marks
|
|
131
|
+
// them for boot resealing. New credentials always use the unified vendor kind.
|
|
132
|
+
if (input.kind === "custom" && input.expectedRevision === null)
|
|
133
|
+
throw new VaultError({ code: "context" });
|
|
89
134
|
await this.authorize({ action: "put", context: input });
|
|
135
|
+
const { identity, existing } = await this.findForPut({ input });
|
|
136
|
+
const meta = this.parsePutMeta({ input, existing });
|
|
137
|
+
const { sealed, last4, tokenLength } = await this.putEnvelope({ input, identity, existing });
|
|
138
|
+
const now = this.deps.now({});
|
|
139
|
+
return { ...identity, provider: input.provider, label: input.label, meta, sealed,
|
|
140
|
+
isDefault: input.isDefault ?? (existing !== null && existing.provider === input.provider && existing.isDefault),
|
|
141
|
+
revision: (input.expectedRevision ?? 0) + 1, last4, tokenLength,
|
|
142
|
+
createdAt: existing === null ? now : existing.createdAt, updatedAt: now };
|
|
143
|
+
}
|
|
144
|
+
/** Resolve the target identity and apply the service-level CAS precheck. */
|
|
145
|
+
async findForPut({ input }) {
|
|
90
146
|
if (input.expectedRevision !== null && !input.id)
|
|
91
147
|
throw new VaultError({ code: "conflict" });
|
|
92
148
|
const identity = { workspaceId: input.workspaceId, kind: input.kind, ownerKey: input.ownerKey,
|
|
@@ -94,21 +150,29 @@ export class SecretsVaultService {
|
|
|
94
150
|
const existing = await this.find(identity);
|
|
95
151
|
if (input.expectedRevision === null ? existing !== null : existing?.revision !== input.expectedRevision)
|
|
96
152
|
throw new VaultError({ code: "conflict" });
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
153
|
+
return { identity, existing };
|
|
154
|
+
}
|
|
155
|
+
parsePutMeta({ input, existing }) {
|
|
156
|
+
// Read defaults keep older sites usable; new records still declare their subtype/compatibility.
|
|
157
|
+
if (!existing && input.kind === "vendor" && !declaresCredential({ meta: input.meta }))
|
|
158
|
+
throw new VaultError({ code: "invalid-meta" });
|
|
159
|
+
const meta = this.deps.metaParsers[input.kind]({ meta: input.meta, provider: input.provider });
|
|
160
|
+
if (existing?.kind === "vendor" && existing.meta.credentialType !== meta.credentialType)
|
|
161
|
+
throw new VaultError({ code: "context" });
|
|
162
|
+
return meta;
|
|
163
|
+
}
|
|
164
|
+
/** Omitted payload keeps the existing envelope and hints without opening or sealing. */
|
|
165
|
+
async putEnvelope({ input, identity, existing }) {
|
|
166
|
+
if (input.payload === undefined) {
|
|
167
|
+
if (!existing)
|
|
168
|
+
throw new VaultError({ code: "invalid-record" });
|
|
169
|
+
return { sealed: existing.sealed, last4: existing.last4, tokenLength: existing.tokenLength };
|
|
170
|
+
}
|
|
171
|
+
const sealed = await this.seal({ context: identity, payload: input.payload });
|
|
101
172
|
// Never expose any suffix of a database address, environment map or OAuth session.
|
|
102
|
-
const hint = sealed && input.hintToken !== undefined && !
|
|
173
|
+
const hint = sealed && input.hintToken !== undefined && !HINTLESS_KINDS.includes(input.kind)
|
|
103
174
|
? credentialTokenHint({ token: input.hintToken }) : null;
|
|
104
|
-
|
|
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;
|
|
175
|
+
return { sealed, last4: hint?.last4 ?? null, tokenLength: hint?.length ?? null };
|
|
112
176
|
}
|
|
113
177
|
/** Authorized runtime read; returns plaintext only to the caller that supplied exact ownership. */
|
|
114
178
|
async get(input, _options = {}) {
|
|
@@ -170,63 +234,67 @@ export class SecretsVaultService {
|
|
|
170
234
|
/** One transfer reuses both services' parsing/open/seal and the repository's default/CAS owner.
|
|
171
235
|
* @complexity Constant row queries plus O(payload size); no group read or source transaction.
|
|
172
236
|
*/
|
|
173
|
-
async copyRow(input
|
|
174
|
-
const {
|
|
237
|
+
async copyRow(input) {
|
|
238
|
+
const { sourceContext, id } = input;
|
|
175
239
|
const result = { kind: sourceContext.kind, id };
|
|
176
240
|
if (!isCopyableKind[sourceContext.kind])
|
|
177
241
|
return { ...result, status: "skipped",
|
|
178
242
|
reasonCode: sourceContext.kind === "mcp-env" ? "requires-mcp-configuration" : "kind-not-copyable" };
|
|
179
243
|
try {
|
|
180
|
-
|
|
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 } : {}) };
|
|
244
|
+
return { ...result, ...(await this.transferRow(input)) };
|
|
218
245
|
}
|
|
219
246
|
catch (error) {
|
|
220
247
|
return { ...result, status: "failed", reasonCode: error instanceof VaultError ? error.code : "copy-failed" };
|
|
221
248
|
}
|
|
222
249
|
}
|
|
250
|
+
/** Guarded read of the source row, then a CAS write of the resealed target row. */
|
|
251
|
+
async transferRow(input) {
|
|
252
|
+
const { source, sourceContext, targetContext, id } = input;
|
|
253
|
+
if (!isCopyTarget({ sourceContext, targetContext, sameRepo: source.deps.repo === this.deps.repo }))
|
|
254
|
+
throw new VaultError({ code: "context" });
|
|
255
|
+
const sourceIdentity = { ...sourceContext, id };
|
|
256
|
+
const targetIdentity = { ...targetContext, id };
|
|
257
|
+
await source.authorize({ action: "get", context: sourceIdentity });
|
|
258
|
+
await this.authorize({ action: "put", context: targetIdentity });
|
|
259
|
+
const raw = await source.deps.repo.find(sourceIdentity);
|
|
260
|
+
if (!raw)
|
|
261
|
+
return { status: "failed", reasonCode: "source-missing" };
|
|
262
|
+
if (buildVaultAad(sourceIdentity) !== buildVaultAad(raw))
|
|
263
|
+
throw new VaultError({ code: "context" });
|
|
264
|
+
// Check before parsing: normal parsers deliberately reject legacy-AAD metadata.
|
|
265
|
+
if (sourceNeedsUpgrade({ raw }))
|
|
266
|
+
return { status: "failed", reasonCode: "source-needs-upgrade" };
|
|
267
|
+
if (isStaleSelection({ selected: input.sourceRevisions, id, revision: raw.revision }))
|
|
268
|
+
throw new VaultError({ code: "conflict" });
|
|
269
|
+
const record = source.parse(raw);
|
|
270
|
+
const existing = await this.find(targetIdentity);
|
|
271
|
+
if (existing && !input.overwrite)
|
|
272
|
+
return { status: "skipped", reasonCode: "target-exists" };
|
|
273
|
+
const last4 = summary(await this.writeCopy({ source, record, targetIdentity, existing })).last4;
|
|
274
|
+
return { status: "copied", ...(last4 ? { last4 } : {}) };
|
|
275
|
+
}
|
|
276
|
+
/** Overwrite keeps the target's default, revision chain and creation time. */
|
|
277
|
+
async writeCopy({ source, record, targetIdentity, existing }) {
|
|
278
|
+
if (existing && !isCompatibleOverwrite({ existing, record }))
|
|
279
|
+
throw new VaultError({ code: "context" });
|
|
280
|
+
const now = this.deps.now({});
|
|
281
|
+
const placement = existing
|
|
282
|
+
? { isDefault: existing.isDefault, revision: existing.revision + 1, createdAt: existing.createdAt }
|
|
283
|
+
: { isDefault: false, revision: 1, createdAt: now };
|
|
284
|
+
const candidate = this.parse({ ...record, ...targetIdentity, ...placement, updatedAt: now });
|
|
285
|
+
candidate.sealed = await this.seal({ context: targetIdentity, payload: await source.open(record) });
|
|
286
|
+
// Reuse the source's hint policy without guessing a token field from a structured payload.
|
|
287
|
+
const hintless = record.kind === "database-destination";
|
|
288
|
+
candidate.last4 = hintless ? null : summary(record).last4;
|
|
289
|
+
candidate.tokenLength = hintless ? null : record.tokenLength;
|
|
290
|
+
return this.deps.repo.put({ record: candidate, expectedRevision: existing ? existing.revision : null });
|
|
291
|
+
}
|
|
223
292
|
/** Human-managed scoped inventory. No envelope or plaintext is opened or returned. */
|
|
224
293
|
async list(input, _options = {}) {
|
|
225
294
|
assertContext(input);
|
|
226
295
|
await this.authorize({ action: "list", context: input });
|
|
227
296
|
return (await this.deps.repo.list(input)).map(record => {
|
|
228
|
-
if (
|
|
229
|
-
record.principalId !== input.principalId || (input.provider !== undefined && record.provider !== input.provider))
|
|
297
|
+
if (!isInQueryScope({ record, query: input }))
|
|
230
298
|
throw new VaultError({ code: "context" });
|
|
231
299
|
return summary(this.parse(record));
|
|
232
300
|
});
|
|
@@ -238,7 +306,9 @@ export class SecretsVaultService {
|
|
|
238
306
|
const existing = await this.find(input);
|
|
239
307
|
if (existing?.revision !== input.expectedRevision)
|
|
240
308
|
throw new VaultError({ code: "conflict" });
|
|
241
|
-
const meta = this.deps.metaParsers[existing.kind]({ meta: { ...existing.meta, ...input.patch } });
|
|
309
|
+
const meta = this.deps.metaParsers[existing.kind]({ meta: { ...existing.meta, ...input.patch }, provider: existing.provider });
|
|
310
|
+
if (existing.kind === "vendor" && existing.meta.credentialType !== meta.credentialType)
|
|
311
|
+
throw new VaultError({ code: "context" });
|
|
242
312
|
// jsonSet can add/replace a key but cannot remove one. Reject parser omissions instead of
|
|
243
313
|
// silently persisting an unvalidated old field. Normalized values and defaults can be patched.
|
|
244
314
|
if (Object.keys({ ...existing.meta, ...input.patch }).some(key => !Object.hasOwn(meta, key)))
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import type { SealedSecret } from "../types.js";
|
|
2
|
-
export declare const VAULT_KINDS: readonly ["site-assistant", "personal-execution", "media-provider", "
|
|
3
|
-
|
|
2
|
+
export declare const VAULT_KINDS: readonly ["site-assistant", "personal-execution", "media-provider", "publish-peer", "database-destination", "vendor", "source-control", "mcp-env", "mcp-auth", "api-connection"];
|
|
3
|
+
/** `custom` remains readable/replaceable until each workspace activates its data migration. */
|
|
4
|
+
export type VaultKind = typeof VAULT_KINDS[number] | "custom";
|
|
5
|
+
export type CredentialType = "vendor" | "custom";
|
|
6
|
+
/** Required metadata on every active vendor-kind credential. Subtype is immutable after create. */
|
|
7
|
+
export type CredentialMeta = VaultMeta & {
|
|
8
|
+
credentialType: CredentialType;
|
|
9
|
+
worksFor: string[];
|
|
10
|
+
};
|
|
4
11
|
export type VaultJson = null | boolean | number | string | VaultJson[] | {
|
|
5
12
|
[key: string]: VaultJson;
|
|
6
13
|
};
|
|
@@ -40,7 +47,7 @@ export interface VaultQuery {
|
|
|
40
47
|
kind: VaultKind;
|
|
41
48
|
ownerKey: string;
|
|
42
49
|
principalId: string | null;
|
|
43
|
-
provider?: string | null;
|
|
50
|
+
provider?: string | null | undefined;
|
|
44
51
|
}
|
|
45
52
|
/** A default belongs to one exact group; omitted provider never means all providers. */
|
|
46
53
|
export interface VaultDefaultQuery extends VaultQuery {
|
|
@@ -86,6 +93,7 @@ export interface VaultCopyResult {
|
|
|
86
93
|
}
|
|
87
94
|
export type VaultMetaParser = (input: {
|
|
88
95
|
meta: unknown;
|
|
96
|
+
provider?: string | null | undefined;
|
|
89
97
|
}, options?: VaultOptions) => VaultMeta;
|
|
90
98
|
export type VaultMetaParsers = Readonly<Record<VaultKind, VaultMetaParser>>;
|
|
91
99
|
/** Safe error codes never contain submitted values or encrypted material. */
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export const VAULT_KINDS = ["site-assistant", "personal-execution", "media-provider", "
|
|
2
|
-
"database-destination", "vendor", "source-control", "mcp-env", "mcp-auth"];
|
|
1
|
+
export const VAULT_KINDS = ["site-assistant", "personal-execution", "media-provider", "publish-peer",
|
|
2
|
+
"database-destination", "vendor", "source-control", "mcp-env", "mcp-auth", "api-connection"];
|
|
3
3
|
/** Safe error codes never contain submitted values or encrypted material. */
|
|
4
4
|
export class VaultError extends Error {
|
|
5
5
|
code;
|
|
@@ -13,16 +13,30 @@ export class VaultError extends Error {
|
|
|
13
13
|
export function vaultGroupKey({ record }, _options = {}) {
|
|
14
14
|
return JSON.stringify([record.workspaceId, record.kind, record.ownerKey, record.principalId, record.provider]);
|
|
15
15
|
}
|
|
16
|
-
/**
|
|
17
|
-
|
|
16
|
+
/** Fields fixed at create and checked on every replacement. */
|
|
17
|
+
// Provider groups defaults, but is not authenticated identity (see buildVaultAad).
|
|
18
|
+
const IMMUTABLE_FIELDS = ["workspaceId", "kind", "ownerKey", "principalId", "id", "binding", "createdAt"];
|
|
19
|
+
function assertRevision({ record, existing, expectedRevision }) {
|
|
18
20
|
if (expectedRevision === null ? existing !== null : existing?.revision !== expectedRevision)
|
|
19
21
|
throw new VaultError({ code: "conflict" });
|
|
20
22
|
if (!Number.isSafeInteger(record.revision) || record.revision !== (expectedRevision ?? 0) + 1)
|
|
21
23
|
throw new VaultError({ code: "invalid-record" });
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
record.id !== existing.id || record.binding !== existing.binding || record.createdAt !== existing.createdAt))
|
|
24
|
+
}
|
|
25
|
+
function assertSameIdentity({ record, existing }) {
|
|
26
|
+
if (IMMUTABLE_FIELDS.some(field => record[field] !== existing[field]))
|
|
26
27
|
throw new VaultError({ code: "context" });
|
|
28
|
+
if (existing.kind === "vendor") {
|
|
29
|
+
// Missing subtype identifies a pre-unification vendor; an explicit null is not a default.
|
|
30
|
+
const previousType = existing.meta.credentialType === undefined ? "vendor" : existing.meta.credentialType;
|
|
31
|
+
const nextType = record.meta.credentialType === undefined ? "vendor" : record.meta.credentialType;
|
|
32
|
+
if (nextType !== previousType)
|
|
33
|
+
throw new VaultError({ code: "context" });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Adapters guard CAS and immutable identity even for callers below the service. */
|
|
37
|
+
export function assertVaultReplacement({ record, existing, expectedRevision }, _options = {}) {
|
|
38
|
+
assertRevision({ record, existing, expectedRevision });
|
|
39
|
+
if (existing)
|
|
40
|
+
assertSameIdentity({ record, existing });
|
|
27
41
|
}
|
|
28
42
|
//# sourceMappingURL=vault.types.js.map
|