@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.
@@ -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
- publish(input: PublishInput): Promise<PublishResult>;
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
- async function commitOnce(input) {
76
- const releaseId = crypto.randomUUID();
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
- async publish(input) {
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.idempotencyKey, input.paramsHash ?? '', () => commitOnce(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);
@@ -5,7 +5,10 @@ export interface EnsurePrivateViewInput {
5
5
  storageScopeId: string;
6
6
  bindingName: string;
7
7
  idempotencyKey: string;
8
- paramsHash: string;
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;
@@ -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(input.idempotencyKey, input.paramsHash, () => bindings.ensure({
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,
@@ -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(uploadId: string, expected: BlobRef): Promise<BlobRef>;
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;
@@ -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
- maxSize: input.maxSize ?? options.maxUploadBytes,
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 session = sessions.get(uploadId);
50
- if (session === undefined)
51
- throw new Error(`unknown upload: ${uploadId}`);
52
- if (session.committed)
53
- throw new Error(`upload already committed: ${uploadId}`);
54
- if (offset !== session.size) {
55
- throw new Error(`upload offset mismatch for ${uploadId}: expected ${session.size}, got ${offset}`);
56
- }
57
- if (session.size + chunk.length > session.maxSize) {
58
- throw new Error(`upload budget exceeded for ${uploadId}: max ${session.maxSize} bytes`);
59
- }
60
- const current = await staging.read(uploadId);
61
- const next = new Uint8Array(current.length + chunk.length);
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
- const session = sessions.get(uploadId);
70
- if (session === undefined)
71
- throw new Error(`unknown upload: ${uploadId}`);
72
- if (session.committed)
73
- throw new Error(`upload already committed: ${uploadId}`);
74
- const bytes = await staging.read(uploadId);
75
- if (bytes.length !== expected.size) {
76
- throw new Error(`upload size mismatch for ${uploadId}: staged ${bytes.length}, declared ${expected.size}`);
77
- }
78
- const actual = `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
79
- if (actual !== expected.digest) {
80
- throw new Error(`upload digest mismatch for ${uploadId}: staged ${actual}`);
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",
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"