@sentropic/artifacts 0.1.3 → 0.1.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/README.md +57 -4
- package/dist/content/handler.js +79 -63
- package/dist/http.d.ts +2 -0
- package/dist/http.js +38 -22
- package/dist/mcp-core.d.ts +62 -0
- package/dist/mcp-core.js +272 -0
- package/dist/mcp.d.ts +4 -60
- package/dist/mcp.js +3 -272
- package/dist/server/publication-limits.d.ts +10 -0
- package/dist/server/publication-limits.js +38 -0
- package/dist/server/publication.d.ts +9 -1
- package/dist/server/publication.js +23 -5
- package/dist/server/service.d.ts +4 -1
- package/dist/server/service.js +6 -1
- package/dist/server/uploads.d.ts +4 -1
- package/dist/server/uploads.js +63 -35
- package/package.json +5 -1
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export const DEFAULT_PUBLICATION_LIMITS = Object.freeze({
|
|
2
|
+
maxFiles: 128, maxFileBytes: 16 * 1024 * 1024, maxTotalBytes: 64 * 1024 * 1024,
|
|
3
|
+
});
|
|
4
|
+
export function publicationLimits(overrides = {}) {
|
|
5
|
+
const limits = { ...DEFAULT_PUBLICATION_LIMITS, ...overrides };
|
|
6
|
+
if (Object.values(limits).some((value) => !Number.isSafeInteger(value) || value <= 0)) {
|
|
7
|
+
throw new Error('invalid publication limits');
|
|
8
|
+
}
|
|
9
|
+
return Object.freeze(limits);
|
|
10
|
+
}
|
|
11
|
+
/** Validate before copying bytes, hashing, idempotency lookup or recording intents. */
|
|
12
|
+
export function validatePublicationFiles(files, limits) {
|
|
13
|
+
if (!Array.isArray(files) || files.length === 0 || files.length > limits.maxFiles) {
|
|
14
|
+
throw new Error('invalid publication file count budget');
|
|
15
|
+
}
|
|
16
|
+
const paths = new Set();
|
|
17
|
+
let total = 0;
|
|
18
|
+
for (const file of files) {
|
|
19
|
+
if (typeof file.path !== 'string' || Buffer.byteLength(file.path) > 1024 ||
|
|
20
|
+
/[\\%?#:\u0000-\u001f\u007f]/.test(file.path) ||
|
|
21
|
+
file.path.split('/').some((part) => part === '' || part === '.' || part === '..')) {
|
|
22
|
+
throw new Error('invalid publication path');
|
|
23
|
+
}
|
|
24
|
+
if (paths.has(file.path))
|
|
25
|
+
throw new Error('duplicate publication path');
|
|
26
|
+
paths.add(file.path);
|
|
27
|
+
if (typeof file.mimeType !== 'string' || file.mimeType.length === 0 ||
|
|
28
|
+
file.mimeType.length > 256 || /[\u0000-\u001f\u007f]/.test(file.mimeType)) {
|
|
29
|
+
throw new Error('invalid publication MIME type');
|
|
30
|
+
}
|
|
31
|
+
if (!(file.bytes instanceof Uint8Array) || file.bytes.byteLength > limits.maxFileBytes) {
|
|
32
|
+
throw new Error('invalid publication file byte budget');
|
|
33
|
+
}
|
|
34
|
+
total += file.bytes.byteLength;
|
|
35
|
+
if (total > limits.maxTotalBytes)
|
|
36
|
+
throw new Error('invalid publication total byte budget');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { JsonValue } from '../contracts.js';
|
|
2
2
|
import type { IntentStore } from './object-intents.js';
|
|
3
3
|
import type { IdempotencyStore } from './idempotency.js';
|
|
4
|
+
import { type SourceRef, type SourceStore } from './sources.js';
|
|
5
|
+
import { type PublicationLimits } from './publication-limits.js';
|
|
6
|
+
export { DEFAULT_PUBLICATION_LIMITS, type PublicationLimits } from './publication-limits.js';
|
|
4
7
|
export interface ReleaseRecord {
|
|
5
8
|
releaseId: string;
|
|
6
9
|
artifactId: string;
|
|
@@ -40,7 +43,9 @@ export interface PublishInput {
|
|
|
40
43
|
artifactId: string;
|
|
41
44
|
profile: Record<string, JsonValue | undefined>;
|
|
42
45
|
files: PublicationFileInput[];
|
|
46
|
+
source?: SourceRef;
|
|
43
47
|
idempotencyKey?: string;
|
|
48
|
+
/** @deprecated Ignored; the server hashes release metadata and file digests. */
|
|
44
49
|
paramsHash?: string;
|
|
45
50
|
}
|
|
46
51
|
export interface PublishResult {
|
|
@@ -48,7 +53,9 @@ export interface PublishResult {
|
|
|
48
53
|
manifest: string;
|
|
49
54
|
}
|
|
50
55
|
export interface PublicationService {
|
|
51
|
-
|
|
56
|
+
readonly limits?: Readonly<PublicationLimits>;
|
|
57
|
+
/** The host source store participates in the idempotent commit when a source is supplied. */
|
|
58
|
+
publish(input: PublishInput, sources?: SourceStore): Promise<PublishResult>;
|
|
52
59
|
getCurrent(artifactId: string): Promise<{
|
|
53
60
|
releaseId: string;
|
|
54
61
|
manifest: string;
|
|
@@ -64,4 +71,5 @@ export declare function createPublicationService(deps: {
|
|
|
64
71
|
blobs: PublicationBlobStore;
|
|
65
72
|
intents: IntentStore;
|
|
66
73
|
idempotency?: IdempotencyStore;
|
|
74
|
+
limits?: Partial<PublicationLimits>;
|
|
67
75
|
}): PublicationService;
|
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { createHash } from 'node:crypto';
|
|
15
15
|
import { canonicalManifest, manifestDigest } from '../codecs.js';
|
|
16
|
+
import { parseSourceRef } from './sources.js';
|
|
17
|
+
import { publicationLimits, validatePublicationFiles } from './publication-limits.js';
|
|
18
|
+
export { DEFAULT_PUBLICATION_LIMITS } from './publication-limits.js';
|
|
16
19
|
/** Derive the `sha256:<hex>` digest from a digest-addressed blob key. */
|
|
17
20
|
export function blobDigestOfKey(key) {
|
|
18
21
|
const hex = key.split('/').pop() ?? '';
|
|
@@ -72,8 +75,8 @@ function shaHex(bytes) {
|
|
|
72
75
|
}
|
|
73
76
|
/** Create a publication service over injected stores (no driver imports). */
|
|
74
77
|
export function createPublicationService(deps) {
|
|
75
|
-
|
|
76
|
-
|
|
78
|
+
const limits = publicationLimits(deps.limits);
|
|
79
|
+
function prepare(input) {
|
|
77
80
|
const entries = input.files.map((file) => {
|
|
78
81
|
const hex = shaHex(file.bytes);
|
|
79
82
|
return {
|
|
@@ -92,6 +95,11 @@ export function createPublicationService(deps) {
|
|
|
92
95
|
};
|
|
93
96
|
const digest = manifestDigest(body);
|
|
94
97
|
const manifest = canonicalManifest({ ...body, digest });
|
|
98
|
+
return { entries, digest, manifest };
|
|
99
|
+
}
|
|
100
|
+
async function commitOnce(input, prepared, sources) {
|
|
101
|
+
const releaseId = crypto.randomUUID();
|
|
102
|
+
const { entries, digest, manifest } = prepared;
|
|
95
103
|
const intent = await deps.intents.record('commit-release', {
|
|
96
104
|
artifactId: input.artifactId,
|
|
97
105
|
releaseId,
|
|
@@ -124,16 +132,26 @@ export function createPublicationService(deps) {
|
|
|
124
132
|
await deps.intents.markFailed(intent.intentId, 'commit race lost: explicit conflict');
|
|
125
133
|
throw new Error(`publication conflict for ${input.artifactId}: concurrent commit won`);
|
|
126
134
|
}
|
|
135
|
+
if (input.source !== undefined)
|
|
136
|
+
await sources.putSource(input.artifactId, input.source);
|
|
127
137
|
await deps.intents.complete(intent.intentId);
|
|
128
138
|
return { releaseId, manifest };
|
|
129
139
|
}
|
|
130
140
|
return {
|
|
131
|
-
|
|
141
|
+
limits,
|
|
142
|
+
async publish(input, sources) {
|
|
143
|
+
validatePublicationFiles(input.files, limits);
|
|
144
|
+
const source = input.source === undefined ? undefined : parseSourceRef(input.source);
|
|
145
|
+
if (source !== undefined && sources === undefined)
|
|
146
|
+
throw new Error('invalid publication: missing source store');
|
|
147
|
+
input = { ...input, source, files: input.files.map((file) => ({ ...file, bytes: new Uint8Array(file.bytes) })) };
|
|
148
|
+
const prepared = prepare(input);
|
|
149
|
+
const requestHash = createHash('sha256').update(JSON.stringify([prepared.digest, source ?? null])).digest('hex');
|
|
132
150
|
if (input.idempotencyKey !== undefined && deps.idempotency !== undefined) {
|
|
133
|
-
const { result } = await deps.idempotency.execute(input.
|
|
151
|
+
const { result } = await deps.idempotency.execute(JSON.stringify(['publish', input.ownerId, input.idempotencyKey]), requestHash, () => commitOnce(input, prepared, sources));
|
|
134
152
|
return result;
|
|
135
153
|
}
|
|
136
|
-
return commitOnce(input);
|
|
154
|
+
return commitOnce(input, prepared, sources);
|
|
137
155
|
},
|
|
138
156
|
async getCurrent(artifactId) {
|
|
139
157
|
const releaseId = await deps.state.getCurrentReleaseId(artifactId);
|
package/dist/server/service.d.ts
CHANGED
|
@@ -5,7 +5,10 @@ export interface EnsurePrivateViewInput {
|
|
|
5
5
|
storageScopeId: string;
|
|
6
6
|
bindingName: string;
|
|
7
7
|
idempotencyKey: string;
|
|
8
|
-
|
|
8
|
+
/** @deprecated Ignored; the service computes its own hash. */
|
|
9
|
+
paramsHash?: string;
|
|
10
|
+
/** Validated provider parameters supplied by the application layer. */
|
|
11
|
+
parameters?: Record<string, string | number | boolean | null>;
|
|
9
12
|
}
|
|
10
13
|
export interface EnsurePrivateViewResult {
|
|
11
14
|
artifactId: string;
|
package/dist/server/service.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { assertArtifactAccess } from './authorization.js';
|
|
11
11
|
import { createBindingService } from './bindings.js';
|
|
12
12
|
import { createIdempotencyStore } from './idempotency.js';
|
|
13
|
+
import { manifestDigest } from '../codecs.js';
|
|
13
14
|
/** Create the artifact service over explicit injected ports (no defaults). */
|
|
14
15
|
export function createArtifactService(ports) {
|
|
15
16
|
const bindings = createBindingService(ports.kv, ports.uuid);
|
|
@@ -21,7 +22,11 @@ export function createArtifactService(ports) {
|
|
|
21
22
|
artifactOwnerId: input.ownerId,
|
|
22
23
|
action: 'mutate',
|
|
23
24
|
});
|
|
24
|
-
const { result, replayed } = await idempotent.execute(
|
|
25
|
+
const { result, replayed } = await idempotent.execute(JSON.stringify(['ensure', input.actorUserId, input.idempotencyKey]), manifestDigest({
|
|
26
|
+
schemaVersion: 'artifacts/v1', ownerId: input.ownerId,
|
|
27
|
+
storageScopeId: input.storageScopeId, bindingName: input.bindingName,
|
|
28
|
+
parameters: input.parameters ?? {},
|
|
29
|
+
}), () => bindings.ensure({
|
|
25
30
|
ownerId: input.ownerId,
|
|
26
31
|
storageScopeId: input.storageScopeId,
|
|
27
32
|
bindingName: input.bindingName,
|
package/dist/server/uploads.d.ts
CHANGED
|
@@ -20,10 +20,13 @@ export interface UploadService {
|
|
|
20
20
|
begin(input: BeginUploadInput): Promise<{
|
|
21
21
|
uploadId: string;
|
|
22
22
|
}>;
|
|
23
|
+
/** Check that the session is open and optionally validate its sequential offset before decoding. */
|
|
24
|
+
remainingBytes(uploadId: string, offset?: number): number;
|
|
23
25
|
append(uploadId: string, offset: number, chunk: Uint8Array): Promise<{
|
|
24
26
|
size: number;
|
|
25
27
|
}>;
|
|
26
|
-
commit
|
|
28
|
+
/** Validate the commit snapshot before consuming the session; rejected validation remains retryable. */
|
|
29
|
+
commit(uploadId: string, expected: BlobRef, validate?: (bytes: Uint8Array) => void): Promise<BlobRef>;
|
|
27
30
|
}
|
|
28
31
|
/** Create an upload service over an injected staging store (no S3 imports). */
|
|
29
32
|
export declare function createUploadService(staging: StagingStore, options: UploadServiceOptions, ids?: () => string): UploadService;
|
package/dist/server/uploads.js
CHANGED
|
@@ -33,54 +33,82 @@ export function createMemoryStaging() {
|
|
|
33
33
|
}
|
|
34
34
|
/** Create an upload service over an injected staging store (no S3 imports). */
|
|
35
35
|
export function createUploadService(staging, options, ids = () => crypto.randomUUID()) {
|
|
36
|
+
if (!Number.isSafeInteger(options.maxUploadBytes) || options.maxUploadBytes < 1) {
|
|
37
|
+
throw new Error('invalid upload byte budget');
|
|
38
|
+
}
|
|
36
39
|
const sessions = new Map();
|
|
40
|
+
function openSession(uploadId) {
|
|
41
|
+
const session = sessions.get(uploadId);
|
|
42
|
+
if (session === undefined)
|
|
43
|
+
throw new Error(`unknown upload: ${uploadId}`);
|
|
44
|
+
if (session.committed)
|
|
45
|
+
throw new Error(`upload already committed: ${uploadId}`);
|
|
46
|
+
if (session.busy)
|
|
47
|
+
throw new Error(`upload conflict: operation in progress for ${uploadId}`);
|
|
48
|
+
return session;
|
|
49
|
+
}
|
|
50
|
+
async function exclusive(uploadId, run) {
|
|
51
|
+
const session = openSession(uploadId);
|
|
52
|
+
session.busy = true;
|
|
53
|
+
try {
|
|
54
|
+
return await run(session);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
session.busy = false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function remainingBytes(uploadId, offset) {
|
|
61
|
+
const session = openSession(uploadId);
|
|
62
|
+
if (offset !== undefined && (!Number.isSafeInteger(offset) || offset !== session.size)) {
|
|
63
|
+
throw new Error(`upload offset mismatch for ${uploadId}: expected ${session.size}, got ${offset}`);
|
|
64
|
+
}
|
|
65
|
+
return session.maxSize - session.size;
|
|
66
|
+
}
|
|
37
67
|
return {
|
|
68
|
+
remainingBytes,
|
|
38
69
|
async begin(input) {
|
|
70
|
+
if (input.maxSize !== undefined && (!Number.isSafeInteger(input.maxSize) || input.maxSize < 1)) {
|
|
71
|
+
throw new Error('invalid upload byte budget');
|
|
72
|
+
}
|
|
39
73
|
const uploadId = ids();
|
|
40
74
|
sessions.set(uploadId, {
|
|
41
75
|
committed: false,
|
|
42
|
-
|
|
76
|
+
busy: false,
|
|
77
|
+
maxSize: Math.min(input.maxSize ?? options.maxUploadBytes, options.maxUploadBytes),
|
|
43
78
|
size: 0,
|
|
44
79
|
});
|
|
45
80
|
await staging.write(uploadId, new Uint8Array(0));
|
|
46
81
|
return { uploadId };
|
|
47
82
|
},
|
|
48
83
|
async append(uploadId, offset, chunk) {
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
next.set(current, 0);
|
|
63
|
-
next.set(chunk, current.length);
|
|
64
|
-
await staging.write(uploadId, next);
|
|
65
|
-
session.size = next.length;
|
|
66
|
-
return { size: session.size };
|
|
84
|
+
const remaining = remainingBytes(uploadId, offset);
|
|
85
|
+
return exclusive(uploadId, async (session) => {
|
|
86
|
+
if (chunk.length > remaining) {
|
|
87
|
+
throw new Error(`upload budget exceeded for ${uploadId}: max ${session.maxSize} bytes`);
|
|
88
|
+
}
|
|
89
|
+
const current = await staging.read(uploadId);
|
|
90
|
+
const next = new Uint8Array(current.length + chunk.length);
|
|
91
|
+
next.set(current, 0);
|
|
92
|
+
next.set(chunk, current.length);
|
|
93
|
+
await staging.write(uploadId, next);
|
|
94
|
+
session.size = next.length;
|
|
95
|
+
return { size: session.size };
|
|
96
|
+
});
|
|
67
97
|
},
|
|
68
|
-
async commit(uploadId, expected) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
}
|
|
82
|
-
session.committed = true;
|
|
83
|
-
return { digest: actual, size: bytes.length };
|
|
98
|
+
async commit(uploadId, expected, validate) {
|
|
99
|
+
return exclusive(uploadId, async (session) => {
|
|
100
|
+
const bytes = new Uint8Array(await staging.read(uploadId));
|
|
101
|
+
validate?.(bytes);
|
|
102
|
+
if (bytes.length !== expected.size) {
|
|
103
|
+
throw new Error(`upload size mismatch for ${uploadId}: staged ${bytes.length}, declared ${expected.size}`);
|
|
104
|
+
}
|
|
105
|
+
const actual = `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
|
|
106
|
+
if (actual !== expected.digest) {
|
|
107
|
+
throw new Error(`upload digest mismatch for ${uploadId}: staged ${actual}`);
|
|
108
|
+
}
|
|
109
|
+
session.committed = true;
|
|
110
|
+
return { digest: actual, size: bytes.length };
|
|
111
|
+
});
|
|
84
112
|
},
|
|
85
113
|
};
|
|
86
114
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sentropic/artifacts",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Generic configurable artifacts MCP and artifact service: StorageScope/Artifact/Binding/Release model, canonical manifests, object authorization, idempotent intents (spec-v8 §9).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -101,6 +101,10 @@
|
|
|
101
101
|
"types": "./dist/mcp.d.ts",
|
|
102
102
|
"import": "./dist/mcp.js"
|
|
103
103
|
},
|
|
104
|
+
"./mcp-core": {
|
|
105
|
+
"types": "./dist/mcp-core.d.ts",
|
|
106
|
+
"import": "./dist/mcp-core.js"
|
|
107
|
+
},
|
|
104
108
|
"./content/grants": {
|
|
105
109
|
"types": "./dist/content/grants.d.ts",
|
|
106
110
|
"import": "./dist/content/grants.js"
|