@sentropic/artifacts 0.2.5 → 0.2.6
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 +19 -0
- package/README.md +13 -3
- package/dist/adapters/postgres.js +11 -4
- package/dist/http.d.ts +3 -2
- package/dist/http.js +221 -36
- package/dist/mcp-core.d.ts +3 -3
- package/dist/mcp-core.js +19 -9
- package/dist/profile/contracts.d.ts +15 -5
- package/dist/profile/contracts.js +9 -6
- package/dist/profile/index.js +33 -4
- package/dist/profile/radar.js +4 -1
- package/dist/profile/schemas.js +31 -5
- package/dist/profile/validation.d.ts +3 -1
- package/dist/profile/validation.js +32 -13
- package/dist/server/access.d.ts +8 -0
- package/dist/server/command-identity.d.ts +4 -2
- package/dist/server/command-identity.js +5 -5
- package/dist/server/commit/coordinator.js +12 -9
- package/dist/server/commit/types.d.ts +4 -0
- package/dist/server/edits.d.ts +17 -0
- package/dist/server/edits.js +43 -0
- package/dist/server/failures.d.ts +31 -2
- package/dist/server/failures.js +14 -3
- package/dist/server/lifecycle.d.ts +2 -0
- package/dist/server/lifecycle.js +3 -0
- package/dist/server/management.d.ts +1 -0
- package/dist/server/management.js +9 -2
- package/dist/server/outcome-projection.d.ts +2 -2
- package/dist/server/outcome-projection.js +25 -9
- package/dist/server/publication.d.ts +18 -1
- package/dist/server/publication.js +54 -5
- package/dist/server/result-schema.d.ts +14 -0
- package/dist/server/result-schema.js +35 -0
- package/dist/server/service.d.ts +2 -0
- package/dist/server/service.js +2 -0
- package/dist/server/summary.d.ts +4 -3
- package/dist/server/summary.js +4 -3
- package/dist/server/templates.js +24 -3
- package/dist/server/versions.d.ts +29 -0
- package/dist/server/versions.js +39 -0
- package/docs/migration-l6.md +8 -1
- package/migrations/0005_metadata.sql +14 -0
- package/package.json +2 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { JsonValue } from '../contracts.js';
|
|
2
2
|
import type { EffectiveArtifactCommand } from '../server/command-identity.js';
|
|
3
3
|
export declare const PROFILE_CONTRACT_VERSION: "artifacts-profile/v1";
|
|
4
|
-
export declare const ARTIFACT_CAPABILITY_NAMES: readonly ["artifacts_get_template", "artifacts_get_api_guide", "artifacts_ensure", "artifacts_publish", "artifacts_create_upload", "artifacts_commit_upload", "artifacts_list", "artifacts_get", "artifacts_get_source", "artifacts_fork", "artifacts_set_access", "artifacts_promote", "artifacts_renew", "artifacts_delete", "artifacts_restore"];
|
|
4
|
+
export declare const ARTIFACT_CAPABILITY_NAMES: readonly ["artifacts_get_template", "artifacts_get_api_guide", "artifacts_ensure", "artifacts_publish", "artifacts_create_upload", "artifacts_commit_upload", "artifacts_list", "artifacts_get", "artifacts_get_source", "artifacts_fork", "artifacts_set_access", "artifacts_promote", "artifacts_renew", "artifacts_delete", "artifacts_restore", "artifacts_list_versions", "artifacts_revert"];
|
|
5
5
|
export type ArtifactCapabilityName = (typeof ARTIFACT_CAPABILITY_NAMES)[number];
|
|
6
6
|
export type ArtifactSchema = Readonly<Record<string, unknown>>;
|
|
7
7
|
export interface OperationProfile {
|
|
@@ -22,6 +22,12 @@ export interface ArtifactProfileRules {
|
|
|
22
22
|
readonly mimeTypes: readonly string[];
|
|
23
23
|
}
|
|
24
24
|
export interface ArtifactProviderProfile {
|
|
25
|
+
/** Trusted public contract revision; independent from the host policy revision. */
|
|
26
|
+
readonly profileRevisionId?: string;
|
|
27
|
+
/** Explicit projection switchover; omitted profiles retain the v1 wire shape. */
|
|
28
|
+
readonly resultEnvelopeVersion?: 1 | 2;
|
|
29
|
+
/** Require caller-supplied CAS preconditions on every publication writer. */
|
|
30
|
+
readonly requireExpectedCurrentReleaseId?: boolean;
|
|
25
31
|
readonly version: typeof PROFILE_CONTRACT_VERSION;
|
|
26
32
|
readonly selectedCapabilities: readonly string[];
|
|
27
33
|
readonly operations: readonly OperationProfile[];
|
|
@@ -42,7 +48,9 @@ export interface CompiledArtifactsProfile extends EffectiveArtifactProfile {
|
|
|
42
48
|
readonly required: true;
|
|
43
49
|
readonly scope: 'principal';
|
|
44
50
|
}>>>;
|
|
45
|
-
validate(name: string, args: unknown, envelopeKey?: string
|
|
51
|
+
validate(name: string, args: unknown, envelopeKey?: string, options?: {
|
|
52
|
+
deferPrecondition: true;
|
|
53
|
+
}): Readonly<Record<string, JsonValue>>;
|
|
46
54
|
assertSelected(name: string): asserts name is ArtifactCapabilityName;
|
|
47
55
|
capabilityForRoute(method: string, path: string): ArtifactCapabilityName | undefined;
|
|
48
56
|
}
|
|
@@ -53,15 +61,17 @@ export interface CompiledValidators {
|
|
|
53
61
|
readonly normalizeCommand: (command: unknown) => EffectiveArtifactCommand;
|
|
54
62
|
}
|
|
55
63
|
/** Projection fields are data-only; invocation correlation is added by L6. */
|
|
64
|
+
export declare const PRECONDITION_REQUIRED_MESSAGE = "expectedCurrentReleaseId is required by the active artifact profile. Re-read this artifact with artifacts_get or artifacts_list, then send expectedCurrentReleaseId using the currentReleaseId returned by that read. Send null only if the read confirms that there is no current release. Before editing an existing release, read it with artifacts_get_source and use that releaseId. The server will not infer this value.";
|
|
56
65
|
export declare class ArtifactProfileInputError extends Error {
|
|
66
|
+
readonly details?: Record<string, unknown> | undefined;
|
|
57
67
|
readonly kind: 'operational' | 'ordinary';
|
|
58
68
|
readonly status: number;
|
|
59
69
|
readonly httpStatus: number;
|
|
60
|
-
readonly variant: 'unselected_operation' | 'invalid_request';
|
|
70
|
+
readonly variant: 'unselected_operation' | 'invalid_request' | 'precondition_required';
|
|
61
71
|
readonly publicCode: string;
|
|
62
|
-
readonly category: 'invalid_request' | 'capability_unselected';
|
|
72
|
+
readonly category: 'invalid_request' | 'capability_unselected' | 'precondition_required';
|
|
63
73
|
readonly retriable = false;
|
|
64
74
|
readonly phase: 'selection' | 'entry';
|
|
65
75
|
readonly commitState = "not_committed";
|
|
66
|
-
constructor(unselected?: boolean);
|
|
76
|
+
constructor(unselected?: boolean, preconditionRequired?: boolean, details?: Record<string, unknown> | undefined);
|
|
67
77
|
}
|
|
@@ -4,10 +4,12 @@ export const ARTIFACT_CAPABILITY_NAMES = [
|
|
|
4
4
|
'artifacts_publish', 'artifacts_create_upload', 'artifacts_commit_upload',
|
|
5
5
|
'artifacts_list', 'artifacts_get', 'artifacts_get_source', 'artifacts_fork',
|
|
6
6
|
'artifacts_set_access', 'artifacts_promote', 'artifacts_renew',
|
|
7
|
-
'artifacts_delete', 'artifacts_restore',
|
|
7
|
+
'artifacts_delete', 'artifacts_restore', 'artifacts_list_versions', 'artifacts_revert',
|
|
8
8
|
];
|
|
9
9
|
/** Projection fields are data-only; invocation correlation is added by L6. */
|
|
10
|
+
export const PRECONDITION_REQUIRED_MESSAGE = 'expectedCurrentReleaseId is required by the active artifact profile. Re-read this artifact with artifacts_get or artifacts_list, then send expectedCurrentReleaseId using the currentReleaseId returned by that read. Send null only if the read confirms that there is no current release. Before editing an existing release, read it with artifacts_get_source and use that releaseId. The server will not infer this value.';
|
|
10
11
|
export class ArtifactProfileInputError extends Error {
|
|
12
|
+
details;
|
|
11
13
|
kind;
|
|
12
14
|
status;
|
|
13
15
|
httpStatus;
|
|
@@ -17,15 +19,16 @@ export class ArtifactProfileInputError extends Error {
|
|
|
17
19
|
retriable = false;
|
|
18
20
|
phase;
|
|
19
21
|
commitState = 'not_committed';
|
|
20
|
-
constructor(unselected = false) {
|
|
21
|
-
super(unselected ? 'capability_unavailable' : 'invalid_request');
|
|
22
|
+
constructor(unselected = false, preconditionRequired = false, details) {
|
|
23
|
+
super(unselected ? 'capability_unavailable' : preconditionRequired ? 'precondition_required' : 'invalid_request');
|
|
24
|
+
this.details = details;
|
|
22
25
|
this.name = 'ArtifactProfileInputError';
|
|
23
26
|
this.kind = unselected ? 'operational' : 'ordinary';
|
|
24
|
-
this.status = unselected ? 503 : 400;
|
|
27
|
+
this.status = unselected ? 503 : preconditionRequired ? 428 : 400;
|
|
25
28
|
this.httpStatus = this.status;
|
|
26
|
-
this.variant = unselected ? 'unselected_operation' : 'invalid_request';
|
|
29
|
+
this.variant = unselected ? 'unselected_operation' : preconditionRequired ? 'precondition_required' : 'invalid_request';
|
|
27
30
|
this.publicCode = this.message;
|
|
28
|
-
this.category = unselected ? 'capability_unselected' : 'invalid_request';
|
|
31
|
+
this.category = unselected ? 'capability_unselected' : preconditionRequired ? 'precondition_required' : 'invalid_request';
|
|
29
32
|
this.phase = unselected ? 'selection' : 'entry';
|
|
30
33
|
}
|
|
31
34
|
}
|
package/dist/profile/index.js
CHANGED
|
@@ -18,11 +18,29 @@ export function compileArtifactsProfile(input) {
|
|
|
18
18
|
const snapshot = copyJson(input);
|
|
19
19
|
const profile = { ...snapshot,
|
|
20
20
|
rules: snapshot.rules === undefined ? copyJson(RADAR_ARTIFACTS_PROFILE.rules) : snapshot.rules };
|
|
21
|
-
const allowed = ['version', 'selectedCapabilities', 'operations', 'contractRevisions', 'rules'
|
|
21
|
+
const allowed = ['version', 'selectedCapabilities', 'operations', 'contractRevisions', 'rules',
|
|
22
|
+
'requireExpectedCurrentReleaseId', 'profileRevisionId', 'resultEnvelopeVersion'];
|
|
22
23
|
if (Object.keys(profile).some((key) => !allowed.includes(key)) || profile.version !== PROFILE_CONTRACT_VERSION ||
|
|
23
24
|
!Array.isArray(profile.selectedCapabilities) || !Array.isArray(profile.operations))
|
|
24
25
|
throw new Error('invalid artifacts profile');
|
|
25
26
|
const requested = profile.selectedCapabilities;
|
|
27
|
+
if (profile.profileRevisionId && profile.resultEnvelopeVersion !== 2 && requested.some(name => ['artifacts_list_versions', 'artifacts_revert'].includes(name)))
|
|
28
|
+
throw new Error('part 1 requires the v2 profile');
|
|
29
|
+
if ((profile.profileRevisionId !== undefined && (typeof profile.profileRevisionId !== 'string' ||
|
|
30
|
+
!profile.profileRevisionId.trim() || profile.profileRevisionId.length > 160 || profile.resultEnvelopeVersion === undefined)) ||
|
|
31
|
+
(profile.resultEnvelopeVersion !== undefined && (!profile.profileRevisionId ||
|
|
32
|
+
![1, 2].includes(profile.resultEnvelopeVersion))) ||
|
|
33
|
+
(profile.resultEnvelopeVersion === 2 && (profile.requireExpectedCurrentReleaseId !== true ||
|
|
34
|
+
profile.contractRevisions?.outcome !== 'artifacts-outcome/v2'))) {
|
|
35
|
+
throw new Error('inconsistent artifact profile descriptor');
|
|
36
|
+
}
|
|
37
|
+
if (profile.requireExpectedCurrentReleaseId !== undefined && typeof profile.requireExpectedCurrentReleaseId !== 'boolean') {
|
|
38
|
+
throw new Error('invalid publication precondition policy');
|
|
39
|
+
}
|
|
40
|
+
if (profile.requireExpectedCurrentReleaseId && requested.some(name => [
|
|
41
|
+
'artifacts_promote', 'artifacts_restore', 'artifacts_create_upload', 'artifacts_commit_upload', 'artifacts_fork',
|
|
42
|
+
].includes(name)))
|
|
43
|
+
throw new Error('strict profile contains a writer without CAS');
|
|
26
44
|
if (new Set(requested).size !== requested.length ||
|
|
27
45
|
requested.some((name) => !ARTIFACT_CAPABILITY_NAMES.includes(name))) {
|
|
28
46
|
throw new Error('unknown or duplicate artifacts capability');
|
|
@@ -73,13 +91,16 @@ export function compileArtifactsProfile(input) {
|
|
|
73
91
|
throw new Error('profile/derivation key length mismatch');
|
|
74
92
|
}
|
|
75
93
|
const defaults = op.defaults;
|
|
94
|
+
if (profile.requireExpectedCurrentReleaseId && Object.hasOwn(defaults, 'expectedCurrentReleaseId')) {
|
|
95
|
+
throw new Error('publication preconditions cannot be defaulted');
|
|
96
|
+
}
|
|
76
97
|
const schema = schemas[name];
|
|
77
98
|
const props = schema.properties;
|
|
78
99
|
for (const [key, value] of Object.entries(defaults)) {
|
|
79
100
|
if (!Object.hasOwn(props, key) || !matchesSchema(value, props[key]))
|
|
80
101
|
throw new Error('profile/schema default mismatch');
|
|
81
102
|
}
|
|
82
|
-
if (['artifacts_ensure', 'artifacts_publish', 'artifacts_commit_upload', 'artifacts_set_access', 'artifacts_delete'].includes(name)) {
|
|
103
|
+
if (['artifacts_ensure', 'artifacts_publish', 'artifacts_commit_upload', 'artifacts_set_access', 'artifacts_delete', 'artifacts_revert'].includes(name)) {
|
|
83
104
|
idempotency[name] = { required: true, scope: 'principal' };
|
|
84
105
|
}
|
|
85
106
|
}
|
|
@@ -93,10 +114,10 @@ export function compileArtifactsProfile(input) {
|
|
|
93
114
|
profileDigest: orderedDigest({ selection: selectedCapabilities, profile, schemas,
|
|
94
115
|
providerContractRevisions: profile.contractRevisions }),
|
|
95
116
|
assertSelected,
|
|
96
|
-
validate(name, args, envelopeKey) {
|
|
117
|
+
validate(name, args, envelopeKey, options) {
|
|
97
118
|
if (!selectedCapabilities.includes(name))
|
|
98
119
|
throw new ArtifactProfileInputError(true);
|
|
99
|
-
return normalizeArguments(profile, name, args, schemas[name], envelopeKey);
|
|
120
|
+
return normalizeArguments(profile, name, args, schemas[name], envelopeKey, options);
|
|
100
121
|
},
|
|
101
122
|
capabilityForRoute(method, path) {
|
|
102
123
|
let name;
|
|
@@ -112,8 +133,16 @@ export function compileArtifactsProfile(input) {
|
|
|
112
133
|
name = 'artifacts_set_access';
|
|
113
134
|
if (method === 'POST' && path === '/delete')
|
|
114
135
|
name = 'artifacts_delete';
|
|
136
|
+
if (method === 'POST' && path === '/revert')
|
|
137
|
+
name = 'artifacts_revert';
|
|
138
|
+
if (method === 'POST' && path === '/list_versions')
|
|
139
|
+
name = 'artifacts_list_versions';
|
|
115
140
|
if (method === 'GET' && path.startsWith('/meta/') && isArtifactId(path.slice(6)))
|
|
116
141
|
name = 'artifacts_get';
|
|
142
|
+
if (method === 'GET' && path.startsWith('/versions/') && isArtifactId(path.slice(10)))
|
|
143
|
+
name = 'artifacts_list_versions';
|
|
144
|
+
if (method === 'GET' && path.startsWith('/source/') && isArtifactId(path.slice(8)))
|
|
145
|
+
name = 'artifacts_get_source';
|
|
117
146
|
return name && selectedCapabilities.includes(name) ? name : undefined;
|
|
118
147
|
},
|
|
119
148
|
};
|
package/dist/profile/radar.js
CHANGED
|
@@ -10,6 +10,9 @@ export const RADAR_ARTIFACTS_PROFILE = freezeDeep({
|
|
|
10
10
|
maxDecodedFileBytes: 262144, maxDecodedTotalBytes: 262144 }, defaults: { profile: {} } },
|
|
11
11
|
{ operation: 'list', limits: { listLimitMin: 1, listLimitMax: 100 }, defaults: { limit: 20, offset: 0 } },
|
|
12
12
|
{ operation: 'get', limits: {}, defaults: {} },
|
|
13
|
+
{ operation: 'list_versions', limits: {}, defaults: {} },
|
|
14
|
+
{ operation: 'get_source', limits: {}, defaults: {} },
|
|
15
|
+
{ operation: 'revert', limits: { idempotencyKeyMaxUnits: 256 }, defaults: {} },
|
|
13
16
|
],
|
|
14
17
|
rules: { lengthUnit: 'utf16', textEncoding: 'utf8', callerKeyNormalization: 'none',
|
|
15
18
|
rejectLoneSurrogates: true, provenance: 'inline-original', keyDerivationAlgorithm: 'radar-derive-idempotency-v1', pathMaxBytes: 1024,
|
|
@@ -17,5 +20,5 @@ export const RADAR_ARTIFACTS_PROFILE = freezeDeep({
|
|
|
17
20
|
reservedFirstSegments: ['api', '__artifacts', 'livez', 'health'],
|
|
18
21
|
mimeTypes: ['text/html', 'text/css', 'text/javascript', 'application/json'] },
|
|
19
22
|
contractRevisions: { profile: 'artifacts-profile/v1', command: 'artifacts-command/v1',
|
|
20
|
-
authorization: 'artifacts-authorization/v1', outcome: 'artifacts-outcome/
|
|
23
|
+
authorization: 'artifacts-authorization/v1', outcome: 'artifacts-outcome/v2' },
|
|
21
24
|
});
|
package/dist/profile/schemas.js
CHANGED
|
@@ -5,6 +5,13 @@ const obj = (properties, required = []) => ({ type: 'object', properties, requir
|
|
|
5
5
|
const id = { type: 'string', pattern: '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' };
|
|
6
6
|
const primitiveRecord = { type: 'object',
|
|
7
7
|
additionalProperties: { type: ['string', 'number', 'boolean', 'null'] }, default: {} };
|
|
8
|
+
/** Artifact display metadata: one short word for the icon. */
|
|
9
|
+
const metadata = {
|
|
10
|
+
title: { type: 'string', minLength: 1, maxLength: 160, description: 'Artifact display title.' },
|
|
11
|
+
description: { type: 'string', maxLength: 2000, description: 'Short artifact description.' },
|
|
12
|
+
icon: { type: ['string', 'null'], pattern: '^[a-z][a-z0-9-]{0,31}$',
|
|
13
|
+
description: 'One short lowercase word.' },
|
|
14
|
+
};
|
|
8
15
|
/** Required configuration includes semantic limits absent from JSON Schema. */
|
|
9
16
|
export function assertCompleteOperation(config) {
|
|
10
17
|
const required = {
|
|
@@ -16,6 +23,7 @@ export function assertCompleteOperation(config) {
|
|
|
16
23
|
commit_upload: { limits: ['idempotencyKeyMaxUnits'], defaults: ['profile'] },
|
|
17
24
|
set_access: { limits: ['idempotencyKeyMaxUnits'], defaults: [] },
|
|
18
25
|
delete: { limits: ['idempotencyKeyMaxUnits'], defaults: [] },
|
|
26
|
+
revert: { limits: ['idempotencyKeyMaxUnits'], defaults: [] },
|
|
19
27
|
};
|
|
20
28
|
const fields = required[config.operation];
|
|
21
29
|
if (fields && (fields.limits.some((key) => !Object.hasOwn(config.limits, key)) ||
|
|
@@ -32,6 +40,7 @@ export function operationProfile(profile, operation) {
|
|
|
32
40
|
/** Schemas are generated from the same limits/defaults read by normalization. */
|
|
33
41
|
export function capabilitySchema(name, profile) {
|
|
34
42
|
const operation = name.slice('artifacts_'.length);
|
|
43
|
+
const part1 = profile.profileRevisionId === undefined || profile.resultEnvelopeVersion === 2;
|
|
35
44
|
const config = operationProfile(profile, operation);
|
|
36
45
|
const limits = config.limits;
|
|
37
46
|
const key = { type: 'string', minLength: 1, maxLength: limits.idempotencyKeyMaxUnits,
|
|
@@ -40,7 +49,7 @@ export function capabilitySchema(name, profile) {
|
|
|
40
49
|
switch (operation) {
|
|
41
50
|
case 'ensure': return obj({ bindingName: { type: 'string', minLength: limits.bindingNameMinUnits,
|
|
42
51
|
maxLength: limits.bindingNameMaxUnits }, parameters: { ...obj({}), default: config.defaults.parameters },
|
|
43
|
-
idempotencyKey: key }, ['bindingName', 'idempotencyKey']);
|
|
52
|
+
idempotencyKey: key, ...(part1 ? metadata : {}) }, ['bindingName', 'idempotencyKey']);
|
|
44
53
|
case 'publish': {
|
|
45
54
|
const reserved = profile.rules.reservedFirstSegments.map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
|
|
46
55
|
const path = { type: 'string', minLength: 1, maxLength: profile.rules.pathMaxBytes,
|
|
@@ -53,9 +62,20 @@ export function capabilitySchema(name, profile) {
|
|
|
53
62
|
bytes: { type: 'array', maxItems: limits.maxDecodedFileBytes, items: integer(0, 255) } }, ['path', 'mimeType']),
|
|
54
63
|
oneOf: [{ required: ['text'], not: { required: ['bytes'] } },
|
|
55
64
|
{ required: ['bytes'], not: { required: ['text'] } }] };
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
65
|
+
const edit = obj({ path, oldStr: { type: 'string', minLength: 1, maxLength: limits.maxDecodedFileBytes,
|
|
66
|
+
description: 'Must occur exactly once in the current file text.' },
|
|
67
|
+
newStr: { type: 'string', maxLength: limits.maxDecodedFileBytes } }, ['path', 'oldStr', 'newStr']);
|
|
68
|
+
return { ...obj({ artifactId: id, idempotencyKey: key,
|
|
69
|
+
files: { type: 'array', items: file, minItems: limits.publishFilesMin, maxItems: limits.publishFilesMax,
|
|
70
|
+
description: 'Full file set; required unless edits is supplied.' },
|
|
71
|
+
...(part1 ? { edits: { type: 'array', items: edit, minItems: 1, maxItems: 100,
|
|
72
|
+
description: 'Small old/new edits applied to the current release text files; required unless files is supplied.' } } : {}),
|
|
73
|
+
expectedCurrentReleaseId: { ...id, type: ['string', 'null'], description: 'Compare the current release atomically; null requires an unpublished artifact.' },
|
|
74
|
+
profile: { ...primitiveRecord, default: config.defaults.profile },
|
|
75
|
+
...(part1 ? metadata : {}) }, ['artifactId', 'idempotencyKey', ...(!part1 ? ['files'] : []),
|
|
76
|
+
...(profile.requireExpectedCurrentReleaseId ? ['expectedCurrentReleaseId'] : [])]),
|
|
77
|
+
oneOf: [{ required: ['files'], not: { required: ['edits'] } },
|
|
78
|
+
{ required: ['edits'], not: { required: ['files'] } }] };
|
|
59
79
|
}
|
|
60
80
|
case 'list': return obj({ limit: { ...integer(limits.listLimitMin, limits.listLimitMax), default: config.defaults.limit },
|
|
61
81
|
offset: { ...integer(0), default: config.defaults.offset } });
|
|
@@ -63,10 +83,16 @@ export function capabilitySchema(name, profile) {
|
|
|
63
83
|
case 'fork':
|
|
64
84
|
case 'renew':
|
|
65
85
|
case 'restore': return idOnly;
|
|
86
|
+
case 'list_versions': return profile.resultEnvelopeVersion !== 2 ? idOnly : obj({ artifactId: id,
|
|
87
|
+
limit: { ...integer(1, 100), default: 20 }, cursor: { ...str, maxLength: 4096 } }, ['artifactId']);
|
|
66
88
|
case 'get_source': return obj({ artifactId: id, releaseId: id,
|
|
67
89
|
paths: { type: 'array', minItems: 1, maxItems: 32, items: { ...str, maxLength: profile.rules.pathMaxBytes } } }, ['artifactId']);
|
|
68
90
|
case 'delete': return obj({ artifactId: id, expectedCurrentReleaseId: { ...id, type: ['string', 'null'] },
|
|
69
|
-
idempotencyKey: key, confirmationRef: { ...str, maxLength: 4096 } }, ['artifactId', '
|
|
91
|
+
idempotencyKey: key, confirmationRef: { ...str, maxLength: 4096 } }, ['artifactId', 'idempotencyKey', ...(profile.requireExpectedCurrentReleaseId ? ['expectedCurrentReleaseId'] : [])]);
|
|
92
|
+
case 'revert': {
|
|
93
|
+
const target = profile.resultEnvelopeVersion === 2 ? 'targetReleaseId' : 'releaseId';
|
|
94
|
+
return obj({ artifactId: id, [target]: id, expectedCurrentReleaseId: { ...id, type: ['string', 'null'] }, idempotencyKey: key }, ['artifactId', target, 'idempotencyKey', ...(profile.requireExpectedCurrentReleaseId ? ['expectedCurrentReleaseId'] : [])]);
|
|
95
|
+
}
|
|
70
96
|
case 'get_template': return obj({ templateId: str }, ['templateId']);
|
|
71
97
|
case 'get_api_guide': return obj({ version: str });
|
|
72
98
|
case 'create_upload': return obj({ artifactId: id, mimeType: str, maxSize: integer(1) }, ['artifactId', 'mimeType']);
|
|
@@ -2,4 +2,6 @@ import { type JsonValue } from '../contracts.js';
|
|
|
2
2
|
import { type EffectiveArtifactProfile, type ArtifactSchema } from './contracts.js';
|
|
3
3
|
/** The generated schema vocabulary, evaluated without a platform dependency. */
|
|
4
4
|
export declare function matchesSchema(value: unknown, schema: ArtifactSchema): boolean;
|
|
5
|
-
export declare function normalizeArguments(profile: EffectiveArtifactProfile, name: string, input: unknown, schema: ArtifactSchema, envelopeKey?: string
|
|
5
|
+
export declare function normalizeArguments(profile: EffectiveArtifactProfile, name: string, input: unknown, schema: ArtifactSchema, envelopeKey?: string, options?: {
|
|
6
|
+
deferPrecondition: true;
|
|
7
|
+
}): Readonly<Record<string, JsonValue>>;
|
|
@@ -72,13 +72,22 @@ export function matchesSchema(value, schema) {
|
|
|
72
72
|
}
|
|
73
73
|
return true;
|
|
74
74
|
}
|
|
75
|
-
export function normalizeArguments(profile, name, input, schema, envelopeKey) {
|
|
75
|
+
export function normalizeArguments(profile, name, input, schema, envelopeKey, options) {
|
|
76
76
|
const args = copyJson(input);
|
|
77
|
+
if (profile.requireExpectedCurrentReleaseId && ['artifacts_publish', 'artifacts_revert', 'artifacts_delete'].includes(name) &&
|
|
78
|
+
isRecord(args) && !Object.hasOwn(args, 'expectedCurrentReleaseId')) {
|
|
79
|
+
if (!options?.deferPrecondition)
|
|
80
|
+
throw new ArtifactProfileInputError(false, true, {
|
|
81
|
+
profileRevisionId: profile.profileRevisionId ?? profile.version, resultEnvelopeVersion: profile.resultEnvelopeVersion ?? 1,
|
|
82
|
+
requiredField: 'expectedCurrentReleaseId', readTools: ['artifacts_get', 'artifacts_list'],
|
|
83
|
+
});
|
|
84
|
+
schema = { ...schema, required: schema.required.filter(key => key !== 'expectedCurrentReleaseId') };
|
|
85
|
+
}
|
|
77
86
|
requireInput(isRecord(args) && matchesSchema(args, schema));
|
|
78
87
|
const operation = name.slice('artifacts_'.length);
|
|
79
88
|
const { defaults, limits } = operationProfile(profile, operation);
|
|
80
89
|
const normalized = { ...copyJson(defaults), ...args };
|
|
81
|
-
if (['ensure', 'publish', 'commit_upload', 'set_access', 'delete'].includes(operation)) {
|
|
90
|
+
if (['ensure', 'publish', 'commit_upload', 'set_access', 'delete', 'revert'].includes(operation)) {
|
|
82
91
|
const key = args.idempotencyKey;
|
|
83
92
|
requireInput(typeof key === 'string' && key.trim().length > 0 && key.length <= limits.idempotencyKeyMaxUnits);
|
|
84
93
|
requireInput(envelopeKey === undefined || envelopeKey === key);
|
|
@@ -91,18 +100,28 @@ export function normalizeArguments(profile, name, input, schema, envelopeKey) {
|
|
|
91
100
|
const paths = args.paths;
|
|
92
101
|
requireInput(new Set(paths).size === paths.length && paths.every((path) => Buffer.byteLength(path, 'utf8') <= profile.rules.pathMaxBytes));
|
|
93
102
|
}
|
|
103
|
+
if (Object.hasOwn(args, 'releaseId'))
|
|
104
|
+
requireInput(isArtifactId(args.releaseId));
|
|
105
|
+
if (Object.hasOwn(args, 'expectedCurrentReleaseId'))
|
|
106
|
+
requireInput(args.expectedCurrentReleaseId === null || isArtifactId(args.expectedCurrentReleaseId));
|
|
94
107
|
if (operation === 'publish') {
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
paths
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
108
|
+
const hasFiles = Object.hasOwn(args, 'files');
|
|
109
|
+
const hasEdits = Object.hasOwn(args, 'edits');
|
|
110
|
+
// Exactly one content source: a full file set, or small edits on the current release.
|
|
111
|
+
requireInput(hasFiles !== hasEdits);
|
|
112
|
+
if (hasFiles) {
|
|
113
|
+
const paths = new Set();
|
|
114
|
+
let total = 0;
|
|
115
|
+
normalized.files = args.files.map((file) => {
|
|
116
|
+
const path = file.path;
|
|
117
|
+
requireInput(!paths.has(path) && Buffer.byteLength(path, 'utf8') <= profile.rules.pathMaxBytes);
|
|
118
|
+
paths.add(path);
|
|
119
|
+
const bytes = Object.hasOwn(file, 'text') ? Array.from(Buffer.from(file.text, 'utf8')) : file.bytes;
|
|
120
|
+
total += bytes.length;
|
|
121
|
+
requireInput(bytes.length <= limits.maxDecodedFileBytes && total <= limits.maxDecodedTotalBytes);
|
|
122
|
+
return { path, mimeType: file.mimeType, bytes };
|
|
123
|
+
});
|
|
124
|
+
}
|
|
106
125
|
}
|
|
107
126
|
return freezeDeep(normalized);
|
|
108
127
|
}
|
package/dist/server/access.d.ts
CHANGED
|
@@ -15,6 +15,12 @@ export interface GrantRecord {
|
|
|
15
15
|
principalId: string;
|
|
16
16
|
grant: AccessGrant;
|
|
17
17
|
}
|
|
18
|
+
/** Display metadata of one artifact (title/description/icon); cosmetic, owner-set. */
|
|
19
|
+
export interface ArtifactMetadata {
|
|
20
|
+
title?: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
icon?: string | null;
|
|
23
|
+
}
|
|
18
24
|
export interface ArtifactAcl {
|
|
19
25
|
bindingName?: string;
|
|
20
26
|
createdAt?: string;
|
|
@@ -28,6 +34,8 @@ export interface ArtifactAcl {
|
|
|
28
34
|
isPublic: boolean;
|
|
29
35
|
withdrawn: boolean;
|
|
30
36
|
grants: GrantRecord[];
|
|
37
|
+
/** Display metadata returned by get/list; persisted by the host store. */
|
|
38
|
+
metadata?: ArtifactMetadata;
|
|
31
39
|
}
|
|
32
40
|
export interface AclStore {
|
|
33
41
|
getArtifactAcl(artifactId: string): Promise<ArtifactAcl | undefined>;
|
|
@@ -8,7 +8,7 @@ export type CallerIdempotencyKey = string & {
|
|
|
8
8
|
export type InternalIdempotencyKey = string & {
|
|
9
9
|
readonly __brand: 'internal-key';
|
|
10
10
|
};
|
|
11
|
-
export type ArtifactCommandOperation = 'ensure' | 'publish' | 'set_access' | 'delete';
|
|
11
|
+
export type ArtifactCommandOperation = 'ensure' | 'publish' | 'set_access' | 'delete' | 'revert';
|
|
12
12
|
export interface CommandIdentity {
|
|
13
13
|
readonly internalKey: InternalIdempotencyKey;
|
|
14
14
|
readonly commandHash: string;
|
|
@@ -50,4 +50,6 @@ export declare function createRadarKeyDerivation(config: {
|
|
|
50
50
|
}): ArtifactKeyDerivationBinding;
|
|
51
51
|
export declare const commandDigest: typeof orderedDigest;
|
|
52
52
|
/** No caller hash, actor, scope, transport or audit identifier enters this API through arguments. */
|
|
53
|
-
export declare function createArtifactCommand(profile: CompiledArtifactsProfile, name: string, args: unknown, authority: ArtifactCommandAuthority, derivation: ArtifactKeyDerivationBinding, envelopeKey?: string
|
|
53
|
+
export declare function createArtifactCommand(profile: CompiledArtifactsProfile, name: string, args: unknown, authority: ArtifactCommandAuthority, derivation: ArtifactKeyDerivationBinding, envelopeKey?: string, options?: {
|
|
54
|
+
deferPrecondition: true;
|
|
55
|
+
}): EffectiveArtifactCommand;
|
|
@@ -20,7 +20,7 @@ export function createRadarKeyDerivation(config) {
|
|
|
20
20
|
if (other === actor || !other.startsWith(`${actor}|`))
|
|
21
21
|
continue;
|
|
22
22
|
const [operation, objectId] = other.slice(actor.length + 1).split('|');
|
|
23
|
-
if ((operation === 'ensure' && objectId === '') || (['publish', 'set_access', 'delete'].includes(operation) && isArtifactId(objectId))) {
|
|
23
|
+
if ((operation === 'ensure' && objectId === '') || (['publish', 'set_access', 'delete', 'revert'].includes(operation) && isArtifactId(objectId))) {
|
|
24
24
|
throw new Error('ambiguous Radar actor domain');
|
|
25
25
|
}
|
|
26
26
|
}
|
|
@@ -30,7 +30,7 @@ export function createRadarKeyDerivation(config) {
|
|
|
30
30
|
derive(input) {
|
|
31
31
|
const { storageScopeId, actorId, operation, objectId, callerKey } = input;
|
|
32
32
|
if (storageScopeId !== scope || !actors.has(actorId) ||
|
|
33
|
-
!['ensure', 'publish', 'set_access', 'delete'].includes(operation) ||
|
|
33
|
+
!['ensure', 'publish', 'set_access', 'delete', 'revert'].includes(operation) ||
|
|
34
34
|
(operation === 'ensure' ? objectId !== '' : !isArtifactId(objectId)))
|
|
35
35
|
throw new ArtifactProfileInputError();
|
|
36
36
|
assertWellFormed(callerKey);
|
|
@@ -43,12 +43,12 @@ export function createRadarKeyDerivation(config) {
|
|
|
43
43
|
}
|
|
44
44
|
export const commandDigest = orderedDigest;
|
|
45
45
|
/** No caller hash, actor, scope, transport or audit identifier enters this API through arguments. */
|
|
46
|
-
export function createArtifactCommand(profile, name, args, authority, derivation, envelopeKey) {
|
|
46
|
+
export function createArtifactCommand(profile, name, args, authority, derivation, envelopeKey, options) {
|
|
47
47
|
assertCompiledArtifactsProfile(profile);
|
|
48
48
|
if (derivation.algorithm !== profile.profile.rules.keyDerivationAlgorithm)
|
|
49
49
|
throw new Error('profile/derivation mismatch');
|
|
50
|
-
const normalized = profile.validate(name, args, envelopeKey);
|
|
51
|
-
if (!['artifacts_ensure', 'artifacts_publish', 'artifacts_set_access', 'artifacts_delete'].includes(name))
|
|
50
|
+
const normalized = profile.validate(name, args, envelopeKey, options);
|
|
51
|
+
if (!['artifacts_ensure', 'artifacts_publish', 'artifacts_set_access', 'artifacts_delete', 'artifacts_revert'].includes(name))
|
|
52
52
|
throw new ArtifactProfileInputError(true);
|
|
53
53
|
const operation = name.slice('artifacts_'.length);
|
|
54
54
|
const { idempotencyKey, confirmationRef: _proof, ...payload } = normalized;
|
|
@@ -407,7 +407,7 @@ export function createArtifactCommitCoordinator(options) {
|
|
|
407
407
|
ok: false,
|
|
408
408
|
failure: failure('conflict', request, phase, 'not_committed', {
|
|
409
409
|
key: request.internalKey,
|
|
410
|
-
}, { variant: '
|
|
410
|
+
}, { variant: 'command_conflict' }),
|
|
411
411
|
};
|
|
412
412
|
}
|
|
413
413
|
return coordinator.replayPrepared(request);
|
|
@@ -514,7 +514,7 @@ export function createArtifactCommitCoordinator(options) {
|
|
|
514
514
|
ok: false,
|
|
515
515
|
failure: failure('conflict', request, 'lock', 'not_committed', {
|
|
516
516
|
key: request.internalKey,
|
|
517
|
-
}, { variant: '
|
|
517
|
+
}, { variant: 'command_conflict' }),
|
|
518
518
|
};
|
|
519
519
|
}
|
|
520
520
|
phase = 'guard';
|
|
@@ -583,6 +583,7 @@ export function createArtifactCommitCoordinator(options) {
|
|
|
583
583
|
if (recheckRevision !== request.policyRevision) {
|
|
584
584
|
return abortWith(request, phase, settle, revokedOutcome(request));
|
|
585
585
|
}
|
|
586
|
+
request.assertPrecondition?.();
|
|
586
587
|
phase = 'commit';
|
|
587
588
|
const commitFailure = await commitUnit(request, active, settle);
|
|
588
589
|
if (commitFailure !== undefined)
|
|
@@ -618,6 +619,12 @@ export function createArtifactCommitCoordinator(options) {
|
|
|
618
619
|
}
|
|
619
620
|
},
|
|
620
621
|
async replayPrepared(request) {
|
|
622
|
+
return await coordinator.findReplayPrepared(request) ?? { ok: false,
|
|
623
|
+
failure: failure('provider_error', request, 'lock', 'not_committed', {
|
|
624
|
+
lost: 'replay without a success row',
|
|
625
|
+
}, { variant: 'unclassified' }) };
|
|
626
|
+
},
|
|
627
|
+
async findReplayPrepared(request) {
|
|
621
628
|
validateRequest(request, 'replay');
|
|
622
629
|
await observeAt('before_connection_acquire', request.invocationId);
|
|
623
630
|
let phase = 'acquire';
|
|
@@ -646,12 +653,8 @@ export function createArtifactCommitCoordinator(options) {
|
|
|
646
653
|
// the barrier denies on the looked-up row instead of vanishing it.
|
|
647
654
|
await observeAt('after_replay_lookup_before_guard', request.invocationId);
|
|
648
655
|
if (existing === undefined) {
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
failure: failure('provider_error', request, phase, 'not_committed', {
|
|
652
|
-
lost: 'replay without a success row',
|
|
653
|
-
}, { variant: 'unclassified' }),
|
|
654
|
-
});
|
|
656
|
+
const error = await settle.abort();
|
|
657
|
+
return error === undefined ? undefined : unknownOutcome(request, phase, error);
|
|
655
658
|
}
|
|
656
659
|
if (existing.codecVersion !== COMMAND_CODEC_VERSION) {
|
|
657
660
|
const settleError = await settle.abort();
|
|
@@ -667,7 +670,7 @@ export function createArtifactCommitCoordinator(options) {
|
|
|
667
670
|
ok: false,
|
|
668
671
|
failure: failure('conflict', request, phase, 'not_committed', {
|
|
669
672
|
key: request.internalKey,
|
|
670
|
-
}, { variant: '
|
|
673
|
+
}, { variant: 'command_conflict' }),
|
|
671
674
|
});
|
|
672
675
|
}
|
|
673
676
|
phase = 'guard';
|
|
@@ -242,6 +242,8 @@ export interface FinalizeRequest {
|
|
|
242
242
|
readonly locks?: Partial<Record<ParticipantLockClass, CommitLockFn[]>>;
|
|
243
243
|
/** Provisional writes before the guard decision. */
|
|
244
244
|
readonly provisional?: (client: FinalizeTxClient) => Promise<void>;
|
|
245
|
+
/** Domain CAS, evaluated under retained locks after current authorization. Never runs on replay. */
|
|
246
|
+
readonly assertPrecondition?: () => void;
|
|
245
247
|
/** Intent completion inside the unit, after the success insert. */
|
|
246
248
|
readonly finishIntents?: (client: FinalizeTxClient) => Promise<void>;
|
|
247
249
|
/** Preparation-abandonment hook, invoked on loser/denial paths. */
|
|
@@ -372,6 +374,8 @@ export interface ArtifactCommitCoordinator {
|
|
|
372
374
|
/** Provider-internal prepared units; domain effects remain inside the same transaction. */
|
|
373
375
|
finalizePrepared(request: FinalizeRequest): Promise<FinalizeResult>;
|
|
374
376
|
replayPrepared(request: FinalizeRequest): Promise<FinalizeResult>;
|
|
377
|
+
/** Guard an existing receipt before preparing content; absence has no side effects. */
|
|
378
|
+
findReplayPrepared(request: FinalizeRequest): Promise<FinalizeResult | undefined>;
|
|
375
379
|
}
|
|
376
380
|
/** Unknown legacy codec on a found success row: L6 projects the migration condition. */
|
|
377
381
|
export declare class CommitQuarantineError extends Error {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface EditableFile {
|
|
2
|
+
readonly path: string;
|
|
3
|
+
readonly mimeType: string;
|
|
4
|
+
/** Decoded text for text mime types; undefined for binary files (not editable). */
|
|
5
|
+
text?: string;
|
|
6
|
+
readonly bytes: Uint8Array;
|
|
7
|
+
}
|
|
8
|
+
export interface EditOperation {
|
|
9
|
+
readonly path: string;
|
|
10
|
+
readonly oldStr: string;
|
|
11
|
+
readonly newStr: string;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Apply edits to a copy of the file list. Throws ArtifactOperationError
|
|
15
|
+
* ('edit_mismatch') on the first violation; the input files are never mutated.
|
|
16
|
+
*/
|
|
17
|
+
export declare function applyEdits(files: readonly EditableFile[], edits: readonly EditOperation[]): EditableFile[];
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parity P0 — small old/new edits on publish.
|
|
3
|
+
*
|
|
4
|
+
* Edits apply to the current release's text files without resending the
|
|
5
|
+
* whole file set. `oldStr` must occur exactly once in the file text; any
|
|
6
|
+
* other count (or an unknown/non-text path) raises a typed `edit_mismatch`
|
|
7
|
+
* before anything is staged, so a failed call changes nothing. The edited
|
|
8
|
+
* files then become the new release through the same commit path.
|
|
9
|
+
*/
|
|
10
|
+
import { ArtifactOperationError } from './failures.js';
|
|
11
|
+
function countOccurrences(haystack, needle) {
|
|
12
|
+
if (needle.length === 0)
|
|
13
|
+
return 0;
|
|
14
|
+
let count = 0;
|
|
15
|
+
let index = haystack.indexOf(needle);
|
|
16
|
+
while (index !== -1) {
|
|
17
|
+
count += 1;
|
|
18
|
+
index = haystack.indexOf(needle, index + Math.max(1, needle.length));
|
|
19
|
+
}
|
|
20
|
+
return count;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Apply edits to a copy of the file list. Throws ArtifactOperationError
|
|
24
|
+
* ('edit_mismatch') on the first violation; the input files are never mutated.
|
|
25
|
+
*/
|
|
26
|
+
export function applyEdits(files, edits) {
|
|
27
|
+
const result = files.map((file) => ({ ...file }));
|
|
28
|
+
for (const [editIndex, edit] of edits.entries()) {
|
|
29
|
+
const file = result.find((entry) => entry.path === edit.path);
|
|
30
|
+
if (file === undefined || file.text === undefined) {
|
|
31
|
+
throw new ArtifactOperationError('edit_mismatch', { edit: { path: edit.path, occurrences: -1,
|
|
32
|
+
editIndex, reason: file === undefined ? 'file_missing' : 'not_text' } });
|
|
33
|
+
}
|
|
34
|
+
const occurrences = countOccurrences(file.text, edit.oldStr);
|
|
35
|
+
if (occurrences !== 1) {
|
|
36
|
+
throw new ArtifactOperationError('edit_mismatch', { edit: { path: edit.path, occurrences,
|
|
37
|
+
editIndex, reason: occurrences === 0 ? 'missing' : 'ambiguous' } });
|
|
38
|
+
}
|
|
39
|
+
file.text = file.text.slice(0, file.text.indexOf(edit.oldStr)) + edit.newStr +
|
|
40
|
+
file.text.slice(file.text.indexOf(edit.oldStr) + edit.oldStr.length);
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|