@pulse-compute/provider-fastly 1.0.0-beta.1 → 1.0.0-beta.3

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.
@@ -1,6 +1,10 @@
1
1
  {
2
2
  "schemaVersion": "pulse.provider-fastly-config-schema.v1",
3
3
  "fields": {
4
+ "fastly.bindings.s3": {
5
+ "kind": "object",
6
+ "default": {}
7
+ },
4
8
  "fastly.configStore": {
5
9
  "kind": "string",
6
10
  "default": "pulse_config"
@@ -16,6 +16,8 @@ function blockedRestriction(id, restriction) {
16
16
 
17
17
  function classifyFastlyJavascriptCapability(id, restrictions = {}) {
18
18
  const capability = String(id);
19
+ if (['kv.getVersioned', 'kv.insertIfAbsent', 'kv.compareAndSwap'].includes(capability)) return decision(capability, 'blocked', 'fastly-conditional-kv-incomplete', 'provider-fastly');
20
+ if (['s3.head', 's3.getText', 's3.putText'].includes(capability)) return decision(capability, 'blocked', 'fastly-javascript-s3-raw-headers-unavailable', 'provider-fastly');
19
21
  if (isJavascriptCoreCapability(capability)) {
20
22
  return decision(capability, 'eligible', 'fastly-request-router-runtime', 'provider-fastly');
21
23
  }
@@ -100,6 +102,8 @@ function classifyFastlyJavascriptCapability(id, restrictions = {}) {
100
102
 
101
103
  function classifyFastlyJavascriptProviderRequirement(id, restrictions = {}) {
102
104
  const requirement = String(id);
105
+ if (['kv.getVersioned', 'kv.insertIfAbsent', 'kv.compareAndSwap'].includes(requirement)) return decision(requirement, 'blocked', 'fastly-conditional-kv-incomplete', 'provider-fastly');
106
+ if (['s3.head', 's3.getText', 's3.putText'].includes(requirement)) return decision(requirement, 'blocked', 'fastly-javascript-s3-raw-headers-unavailable', 'provider-fastly');
103
107
  if (requirement === 'request' || ['response.json', 'response.text', 'response.custom'].includes(requirement)) {
104
108
  return decision(requirement, 'eligible', 'fastly-request-response-lifecycle', 'provider-fastly');
105
109
  }
@@ -13,9 +13,9 @@ const FASTLY_PROVIDER_DESCRIPTOR = canonical.normalizeDescriptor({
13
13
  localExecution: true,
14
14
  capabilities: [
15
15
  'request', 'response.json', 'response.text', 'response.custom', 'fetch',
16
- 'config.get', 'secret.get', 'kv.get', 'kv.put', 'assets.lookup',
16
+ 'config.get', 'secret.get', 'kv.get', 'kv.put', 'kv.getVersioned', 'kv.insertIfAbsent', 'kv.compareAndSwap', 'assets.lookup',
17
17
  'grip.channel', 'grip.hold', 'grip.publish', 'grip.broadcast', 'jwt.verify',
18
- 'opaque.pass-through'
18
+ 'opaque.pass-through', 's3.head', 's3.getText', 's3.putText'
19
19
  ],
20
20
  lowering: {
21
21
  request: 'fastly.compute.request',
@@ -27,12 +27,18 @@ const FASTLY_PROVIDER_DESCRIPTOR = canonical.normalizeDescriptor({
27
27
  'secret.get': 'fastly.secret-store.get',
28
28
  'kv.get': 'fastly.kv-store.get',
29
29
  'kv.put': 'fastly.kv-store.put',
30
+ 'kv.getVersioned': 'fastly.native.kv-store.getVersioned',
31
+ 'kv.insertIfAbsent': 'fastly.native.kv-store.insertIfAbsent',
32
+ 'kv.compareAndSwap': 'fastly.native.kv-store.compareAndSwap',
30
33
  'assets.lookup': 'fastly.kv-store.assets.lookup',
31
34
  'grip.channel': 'fastly.fanout.channel',
32
35
  'grip.hold': 'fastly.fanout.hold',
33
36
  'grip.publish': 'fastly.fanout.publish',
34
37
  'grip.broadcast': 'fastly.grip.publish-control',
35
38
  'jwt.verify': 'fastly.native.jwt.verify',
39
+ 's3.head': 'fastly.native.s3.head',
40
+ 's3.getText': 'fastly.native.s3.getText',
41
+ 's3.putText': 'fastly.native.s3.putText',
36
42
  'opaque.pass-through': 'fastly.response-body.stream'
37
43
  }
38
44
  });
@@ -53,6 +59,7 @@ function validateStaticBackendBindings(metadata = {}, bindings = {}) {
53
59
  }
54
60
 
55
61
  function createFastlyLoweringPlan(metadata, bindings = {}) {
62
+ require('./toolchain/s3.js').resolveFastlyS3(metadata.providerOperations || [], bindings.s3);
56
63
  validateStaticBackendBindings(metadata, bindings);
57
64
  return canonical.createProviderLoweringPlan(metadata, FASTLY_PROVIDER_DESCRIPTOR, {
58
65
  ...bindings,
@@ -337,6 +337,10 @@ function createFastlyProviderAdapter(baseOptions = {}) {
337
337
  return Object.freeze({
338
338
  id: 'fastly',
339
339
  version: CANONICAL_FASTLY_RUNTIME_VERSION,
340
+ // This legacy JavaScript fixture surface has no Native KV host ABI. Keep
341
+ // that absence pre-dispatch/configuration, rather than pretending a failed
342
+ // fixture dispatch might have mutated storage. Real K3 evidence runs Wasm.
343
+ prepareConditionalKv() { return undefined; },
340
344
  async dispatchEffect(effect, executionOptions = {}) {
341
345
  const state = stateFor(executionOptions);
342
346
  if (effect.kind === 'fetch') return dispatchFetch(effect, executionOptions);
@@ -0,0 +1,109 @@
1
+ 'use strict';
2
+
3
+ // Injected ABI authority for real generated Wasm evidence, never a service adapter.
4
+ // Raw persisted bytes and u64 generations remain independent of the Pulse codec.
5
+ function createConditionalKvAuthority() {
6
+ const stores = new Map(); let next = 1n;
7
+ return {
8
+ stores,
9
+ seed(store, key, wire, generation = next++) {
10
+ if (!stores.has(store)) stores.set(store, new Map());
11
+ stores.get(store).set(key, { wire: Buffer.from(wire), generation: BigInt(generation) });
12
+ },
13
+ get(store, key) { return stores.get(store)?.get(key); },
14
+ apply(store, key, wire, mode, condition) {
15
+ const values = stores.get(store), current = values.get(key);
16
+ if (mode === 1 && current || condition !== undefined && (!current || current.generation !== condition)) return 4;
17
+ while (next === current?.generation) next++;
18
+ this.seed(store, key, wire, next++); return 1;
19
+ }
20
+ };
21
+ }
22
+
23
+ function attachConditionalKvHost(imports, host, options) {
24
+ const { view, writeU32, writeU64, readBytes, readUtf8, alloc, bodies, bodyBytes, trace, now, advance } = host;
25
+ const authority = options.authority, stores = new Map(), pending = new Map(), bodyFixtures = new Map();
26
+ let sendingResponse = false;
27
+ const newResponse = imports.fastly_http_resp.new;
28
+ imports.fastly_http_resp.new = (...args) => { sendingResponse = true; return newResponse(...args); };
29
+ const call = (stage, detail = {}) => {
30
+ const fault = typeof options.onCall === 'function' ? options.onCall(stage, detail) || {} : {};
31
+ advance(Number(fault.delayMs || 0));
32
+ trace.push({ module: 'pulse_kv_evidence', name: stage, hostStatus: fault.status ?? 0, ...(fault.kvError === undefined ? {} : { kvError: fault.kvError }) });
33
+ return fault;
34
+ };
35
+ imports.fastly_kv_store = {
36
+ open(ptr, len, out) {
37
+ const name = readUtf8(ptr, len), fault = call('open', { store: name });
38
+ if (fault.status) return fault.status;
39
+ if (!authority.stores.has(name)) return 10;
40
+ const handle = alloc('kvStore'); stores.set(handle, name); writeU32(out, handle); return 0;
41
+ },
42
+ lookup(store, ptr, len, mask, config, out) {
43
+ const name = stores.get(store), key = readUtf8(ptr, len), fault = call('lookup', { store: name, key, mask, config: readBytes(config, 4) });
44
+ if (fault.status) return fault.status;
45
+ const item = fault.observation || authority.get(name, key);
46
+ const handle = alloc('kvLookup'); pending.set(handle, { kind: 'lookup', item, readyAt: now() + Number(fault.readyDelayMs || 0), fault });
47
+ writeU32(out, handle); return 0;
48
+ },
49
+ lookup_wait_v2(handle, body, meta, capacity, written, generation, error) {
50
+ const item = pending.get(handle); if (!item || item.readyAt > now()) throw new Error('KV lookup wait called before readiness.');
51
+ pending.delete(handle); const fault = call('lookup_wait_v2', { capacity, observation: item.item });
52
+ writeU32(error, fault.kvError ?? (item.item ? 1 : 3)); writeU64(generation, fault.generation ?? item.item?.generation ?? 0n);
53
+ writeU32(written, fault.metadataLength ?? 0);
54
+ if (fault.status) return fault.status;
55
+ if ((fault.kvError ?? (item.item ? 1 : 3)) !== 1) return 0;
56
+ const handleOut = alloc('body'); const bytes = Buffer.from(fault.wire || item.item.wire);
57
+ bodies.set(handleOut, { bytes, readOffset: 0, writes: [], readyAt: now() + Number(fault.bodyDelayMs || 0) });
58
+ bodyFixtures.set(handleOut, fault); writeU32(body, handleOut); return 0;
59
+ },
60
+ insert(store, ptr, len, body, mask, config, out) {
61
+ const name = stores.get(store), key = readUtf8(ptr, len), wire = Buffer.from(bodyBytes(body));
62
+ const mode = view().getUint32(config, true), condition = mask & 32 ? view().getBigUint64(config + 24, true) : undefined;
63
+ const fault = call('insert', { store: name, key, wire, mode, mask, condition, config: readBytes(config, 32) });
64
+ let code = fault.kvError;
65
+ if (code === undefined && (!fault.status || fault.commit)) code = authority.apply(name, key, wire, mode, condition);
66
+ bodies.delete(body); // Host takes ownership of the body.
67
+ if (fault.status) return fault.status;
68
+ const handle = alloc('kvInsert'); pending.set(handle, { kind: 'insert', code, readyAt: now() + Number(fault.readyDelayMs || 0) });
69
+ writeU32(out, handle); return 0;
70
+ },
71
+ insert_wait(handle, error) {
72
+ const item = pending.get(handle); if (!item || item.readyAt > now()) throw new Error('KV insert wait called before readiness.');
73
+ pending.delete(handle); const fault = call('insert_wait'); writeU32(error, fault.kvError ?? item.code ?? 0); return fault.status ?? 0;
74
+ }
75
+ };
76
+ const select = imports.fastly_async_io.select;
77
+ imports.fastly_async_io.select = (handles, count, timeout, done) => {
78
+ const handle = view().getUint32(handles, true), item = pending.get(handle) || (bodyFixtures.has(handle) && bodies.get(handle));
79
+ if (!item) return select(handles, count, timeout, done);
80
+ if (count !== 1 || timeout <= 0) throw new Error('KV select must be bounded.');
81
+ const fault = call('select', { kind: item.kind || 'body', timeout });
82
+ if (fault.status) return fault.status;
83
+ const wait = Math.max(0, item.readyAt - now()); advance(Math.min(wait, timeout));
84
+ writeU32(done, fault.readyIndex ?? (wait >= timeout ? 0xffffffff : 0)); return 0;
85
+ };
86
+ for (const name of ['new', 'write', 'read', 'close']) {
87
+ const original = imports.fastly_http_body[name];
88
+ imports.fastly_http_body[name] = (...args) => {
89
+ const [handle] = args;
90
+ if (sendingResponse) return original(...args);
91
+ if (name === 'read' && !bodyFixtures.has(handle)) return original(...args);
92
+ const fault = call('body_' + name, { handle });
93
+ if (fault.status) return fault.status;
94
+ const result = original(...args);
95
+ if (name === 'read') {
96
+ const fixture = bodyFixtures.get(handle), body = bodies.get(handle);
97
+ if (body) body.readyAt = now() + Number(fixture.chunkDelayMs || 0);
98
+ if (fault.written !== undefined) writeU32(args[3], fault.written);
99
+ }
100
+ if (name === 'write' && fault.written !== undefined) writeU32(args[4], fault.written);
101
+ if (name === 'close') bodyFixtures.delete(handle);
102
+ return result;
103
+ };
104
+ }
105
+ const clock = imports.wasi_snapshot_preview1.clock_time_get;
106
+ imports.wasi_snapshot_preview1.clock_time_get = (...args) => { const fault = call('clock'); return fault.status || clock(...args); };
107
+ return { pending, bodyFixtures };
108
+ }
109
+ module.exports = { createConditionalKvAuthority, attachConditionalKvHost };
@@ -10,7 +10,7 @@ const FASTLY_STATUS_BUFLEN = 4;
10
10
  const FASTLY_STATUS_NONE = 10;
11
11
  const FASTLY_KV_ERROR_OK = 1;
12
12
  const FASTLY_KV_ERROR_NOT_FOUND = 3;
13
- const SENSITIVE_HEADERS = new Set(['authorization', 'proxy-authorization', 'x-api-key', 'x-auth-token', 'cookie', 'set-cookie']);
13
+ const SENSITIVE_HEADERS = new Set(['authorization', 'proxy-authorization', 'x-api-key', 'x-auth-token', 'cookie', 'set-cookie', 'x-amz-security-token', 'x-amz-content-sha256']);
14
14
 
15
15
  class FastlyNativePlatformCapabilitiesMockError extends Error {
16
16
  constructor(message, code = 'PULSE_FASTLY_NATIVE_PLATFORM_CAPABILITIES_MOCK_FAILED', detail = {}) {
@@ -113,6 +113,8 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
113
113
  const url = String(requestInput.url || `https://pulse.test${pathValue.startsWith('/') ? pathValue : `/${pathValue}`}`);
114
114
  const requestHeaders = normalizeHeaders(requestInput.headers);
115
115
  const trace = [];
116
+ let monotonicMs = 0;
117
+ const privateUrls = new Set();
116
118
  let nextHandle = 10;
117
119
  const typedHandleStarts = options.handleStarts && typeof options.handleStarts === 'object' ? options.handleStarts : {};
118
120
  const typedHandleNext = new Map();
@@ -127,7 +129,7 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
127
129
  const secretStores = normalizeNamedStores(options.secretStores, options.secretStore || 'app_secrets', options.secrets);
128
130
  const secretValues = [...secretStores.values()].flatMap((values) => [...values.values()].map((value) => String(value))).filter(Boolean);
129
131
  const redactText = (value) => secretValues.reduce((output, secret) => output.split(secret).join('[REDACTED]'), String(value));
130
- const redactRuntimeHeaders = (headers) => headers.map(([name, value]) => [name, SENSITIVE_HEADERS.has(String(name).toLowerCase()) ? '[REDACTED]' : redactText(value)]);
132
+ const redactRuntimeHeaders = (headers, privateS3 = false) => headers.map(([name, value]) => [name, SENSITIVE_HEADERS.has(String(name).toLowerCase()) || privateS3 && ['etag', 'content-type'].includes(String(name).toLowerCase()) ? '[REDACTED]' : redactText(value)]);
131
133
  const kvStores = normalizeNamedStores(options.kvStores, options.kvStore || 'app_sessions', undefined);
132
134
  for (const values of kvStores.values()) for (const [key, value] of values) values.set(key, encodeKvValue(value));
133
135
  const configHandles = new Map();
@@ -206,17 +208,37 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
206
208
  headers,
207
209
  origin: true
208
210
  });
209
- bodies.set(bodyHandle, { bytes: normalizeBody(body), readOffset: 0, writes: [] });
211
+ bodies.set(bodyHandle, { bytes: normalizeBody(body), readOffset: 0, writes: [], fixture: normalized, readyAt: monotonicMs + Number(normalized.bodyDelayMs || 0) });
210
212
  return { responseHandle, bodyHandle };
211
213
  }
212
214
 
215
+ function headerList(values, buffer, size, end, written) {
216
+ const output = Buffer.from(values.map((value) => String(value) + '\0').join(''));
217
+ writeU32(written, output.length); view().setBigInt64(Number(end), -1n, true);
218
+ if (output.length > Number(size)) return FASTLY_STATUS_BUFLEN;
219
+ new Uint8Array(memory().buffer, Number(buffer), output.length).set(output);
220
+ return FASTLY_STATUS_OK;
221
+ }
213
222
  const imports = {
223
+ fastly_async_io: {
224
+ select(handles, count, timeout, done) {
225
+ if (Number(count) !== 1 || Number(timeout) <= 0) return FASTLY_STATUS_ERROR;
226
+ const handle = view().getUint32(Number(handles), true);
227
+ const item = pending.get(handle) || bodies.get(handle);
228
+ if (!item) return FASTLY_STATUS_BADF;
229
+ const wait = Math.max(0, Number(item.readyAt || 0) - monotonicMs);
230
+ monotonicMs += Math.min(wait, Number(timeout));
231
+ writeU32(done, wait >= Number(timeout) ? 0xffffffff : 0);
232
+ trace.push({ module: 'fastly_async_io', name: 'select', timeout: Number(timeout), timedOut: wait >= Number(timeout) });
233
+ return FASTLY_STATUS_OK;
234
+ }
235
+ },
214
236
  wasi_snapshot_preview1: {
215
237
  clock_time_get(clockId, precision, timeOut) {
216
238
  const seconds = Number.isFinite(Number(options.clockUnixSeconds))
217
239
  ? Number(options.clockUnixSeconds)
218
240
  : Math.floor(Date.now() / 1000);
219
- writeU64(timeOut, BigInt(Math.trunc(seconds * 1_000_000_000)));
241
+ writeU64(timeOut, BigInt(Math.trunc((Number(clockId) === 1 ? monotonicMs : seconds * 1000 + monotonicMs) * 1_000_000)));
220
242
  trace.push({
221
243
  module: 'wasi_snapshot_preview1',
222
244
  name: 'clock_time_get',
@@ -316,33 +338,54 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
316
338
  if (!request) return FASTLY_STATUS_BADF;
317
339
  const name = readUtf8(namePointer, nameLength);
318
340
  const value = readUtf8(valuePointer, valueLength);
341
+ if (name.toLowerCase() === 'authorization' && value.includes('/s3/aws4_request')) privateUrls.add(request.url);
319
342
  request.headers = request.headers.filter(([headerName]) => headerName.toLowerCase() !== name.toLowerCase());
320
343
  request.headers.push([name, value]);
321
- trace.push({ module: 'fastly_http_req', name: 'header_insert', handle: Number(handle), header: [name, SENSITIVE_HEADERS.has(name.toLowerCase()) ? '[REDACTED]' : redactText(value)] });
344
+ trace.push({ module: 'fastly_http_req', name: 'header_insert', handle: Number(handle), header: redactRuntimeHeaders([[name, value]], privateUrls.has(request.url))[0] });
322
345
  return FASTLY_STATUS_OK;
323
346
  },
347
+ cache_override_set(handle, tag, ttl, swr) {
348
+ const request = requests.get(Number(handle)); if (!request) return FASTLY_STATUS_BADF;
349
+ request.cacheOverride = Number(tag); return FASTLY_STATUS_OK;
350
+ },
351
+ auto_decompress_response_set(handle, encodings) {
352
+ const request = requests.get(Number(handle)); if (!request) return FASTLY_STATUS_BADF;
353
+ request.decompression = Number(encodings); return FASTLY_STATUS_OK;
354
+ },
355
+ close(handle) { return requests.delete(Number(handle)) ? FASTLY_STATUS_OK : FASTLY_STATUS_BADF; },
356
+ pending_req_poll_v2(handle, detail, done, responseOut, bodyOut) {
357
+ const item = pending.get(Number(handle)); if (!item) return FASTLY_STATUS_BADF;
358
+ if (monotonicMs < item.readyAt) { writeU32(done, 0); return FASTLY_STATUS_OK; }
359
+ writeU32(done, 1); return imports.fastly_http_req.pending_req_wait(handle, responseOut, bodyOut);
360
+ },
324
361
  send_async(handle, bodyHandle, backendPointer, backendLength, pendingOut) {
325
362
  const request = requests.get(Number(handle));
326
363
  const body = bodies.get(Number(bodyHandle));
327
364
  if (!request || !body) return FASTLY_STATUS_BADF;
328
365
  const backend = readUtf8(backendPointer, backendLength);
366
+ const signedS3 = request.headers.some(([name, value]) => name.toLowerCase() === 'authorization' && value.startsWith('AWS4-HMAC-SHA256 '));
367
+ if (signedS3) privateUrls.add(request.url);
329
368
  const fixture = resolveFixture(options.fixtures, backend, request.method, request.url);
330
369
  if (!fixture) {
331
- trace.push({ module: 'fastly_http_req', name: 'send_async', handle: Number(handle), backend, method: request.method, url: request.url, missingFixture: true });
370
+ trace.push({ module: 'fastly_http_req', name: 'send_async', handle: Number(handle), backend, method: request.method, url: signedS3 ? '[REDACTED]' : request.url, missingFixture: true });
332
371
  return FASTLY_STATUS_ERROR;
333
372
  }
334
373
  const pendingHandle = alloc('pending');
335
374
  const capturedBody = bodyBytes(bodyHandle);
336
- pending.set(pendingHandle, { request: { ...request, headers: request.headers.map((entry) => [...entry]), body: capturedBody }, backend, fixture });
375
+ pending.set(pendingHandle, { request: { ...request, headers: request.headers.map((entry) => [...entry]), body: capturedBody }, backend, fixture, readyAt: monotonicMs + Number(fixture.delayMs || 0) });
376
+ if (typeof options.onOutboundRequest === 'function') options.onOutboundRequest({ ...request, backend, headers: request.headers.map((entry) => [...entry]), body: capturedBody });
377
+ if (fixture.sendStatus) { pending.delete(pendingHandle); return Number(fixture.sendStatus); }
378
+ if (request.headers.some(([name, value]) => name.toLowerCase() === 'authorization' && value.startsWith('AWS4-HMAC-SHA256 '))) privateUrls.add(request.url);
379
+ requests.delete(Number(handle)); bodies.delete(Number(bodyHandle));
337
380
  outboundRequests.push(Object.freeze({
338
381
  backend,
339
382
  method: request.method,
340
- url: redactText(request.url),
341
- headers: Object.freeze(redactRuntimeHeaders(request.headers).map((header) => Object.freeze(header))),
342
- body: redactText(capturedBody.toString('utf8'))
383
+ url: privateUrls.has(request.url) ? '[REDACTED]' : redactText(request.url),
384
+ headers: Object.freeze(redactRuntimeHeaders(request.headers, signedS3).map((header) => Object.freeze(header))),
385
+ body: signedS3 ? '[REDACTED]' : redactText(capturedBody.toString('utf8'))
343
386
  }));
344
387
  writeU32(pendingOut, pendingHandle);
345
- trace.push({ module: 'fastly_http_req', name: 'send_async', handle: Number(handle), bodyHandle: Number(bodyHandle), pendingHandle, backend, method: request.method, url: redactText(request.url), headers: redactRuntimeHeaders(request.headers), bodyBytes: capturedBody.length });
388
+ trace.push({ module: 'fastly_http_req', name: 'send_async', handle: Number(handle), bodyHandle: Number(bodyHandle), pendingHandle, backend, method: request.method, url: redactText(request.url), headers: redactRuntimeHeaders(request.headers, signedS3), bodyBytes: capturedBody.length });
346
389
  return FASTLY_STATUS_OK;
347
390
  },
348
391
  pending_req_wait(handle, responseOut, bodyOut) {
@@ -384,6 +427,8 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
384
427
  const handle = alloc('secretStore'); secretStoreHandles.set(handle, values); writeU32(handleOut, handle); return FASTLY_STATUS_OK;
385
428
  },
386
429
  get(handle, keyPointer, keyLength, secretOut) {
430
+ monotonicMs += Number(options.secretDelayMs || 0);
431
+ if (typeof options.onSecretLookup === 'function') options.onSecretLookup();
387
432
  const values = secretStoreHandles.get(Number(handle));
388
433
  const key = readUtf8(keyPointer, keyLength);
389
434
  const value = values && values.get(key);
@@ -447,6 +492,16 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
447
492
  }
448
493
  },
449
494
  fastly_http_resp: {
495
+ close(handle) { trace.push({ module: 'fastly_http_resp', name: 'close' }); return responses.delete(Number(handle)) ? FASTLY_STATUS_OK : FASTLY_STATUS_BADF; },
496
+ header_names_get(handle, buffer, size, cursor, end, written) {
497
+ const response = responses.get(Number(handle)); if (!response || cursor !== 0) return FASTLY_STATUS_BADF;
498
+ return headerList([...new Set(response.headers.map(([name]) => name.toLowerCase()))], buffer, size, end, written);
499
+ },
500
+ header_values_get(handle, pointer, length, buffer, size, cursor, end, written) {
501
+ const response = responses.get(Number(handle)); if (!response || cursor !== 0) return FASTLY_STATUS_BADF;
502
+ const name = readUtf8(pointer, length).toLowerCase();
503
+ return headerList(response.headers.filter(([key]) => key.toLowerCase() === name).map(([, value]) => value), buffer, size, end, written);
504
+ },
450
505
  new(handleOut) {
451
506
  const handle = alloc('response');
452
507
  responses.set(handle, { status: 200, headers: [], origin: false });
@@ -503,6 +558,7 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
503
558
  }
504
559
  },
505
560
  fastly_http_body: {
561
+ close(handle) { trace.push({ module: 'fastly_http_body', name: 'close' }); return bodies.delete(Number(handle)) ? FASTLY_STATUS_OK : FASTLY_STATUS_BADF; },
506
562
  new(handleOut) {
507
563
  const handle = alloc('body');
508
564
  bodies.set(handle, { bytes: Buffer.alloc(0), readOffset: 0, writes: [] });
@@ -513,11 +569,13 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
513
569
  read(handle, buffer, bufferLength, readOut) {
514
570
  const body = bodies.get(Number(handle));
515
571
  if (!body) return FASTLY_STATUS_BADF;
572
+ if (body.fixture && body.fixture.bodyReadStatus) return Number(body.fixture.bodyReadStatus);
516
573
  const source = bodyBytes(handle);
517
574
  const offset = body.readOffset || 0;
518
- const count = Math.min(Number(bufferLength), Math.max(0, source.length - offset));
575
+ const count = Math.min(Number(bufferLength), Math.max(0, source.length - offset), Number(body.fixture && body.fixture.chunkBytes || 65536));
519
576
  if (count > 0) new Uint8Array(memory().buffer, Number(buffer), count).set(source.subarray(offset, offset + count));
520
577
  body.readOffset = offset + count;
578
+ body.readyAt = monotonicMs + Number(body.fixture && body.fixture.chunkDelayMs || 0);
521
579
  writeU32(readOut, count);
522
580
  trace.push({ module: 'fastly_http_body', name: 'read', handle: Number(handle), bytes: count });
523
581
  return FASTLY_STATUS_OK;
@@ -525,7 +583,9 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
525
583
  write(handle, buffer, bufferLength, end, writtenOut) {
526
584
  const body = bodies.get(Number(handle));
527
585
  if (!body) return FASTLY_STATUS_BADF;
528
- const bytes = readBytes(buffer, bufferLength);
586
+ if (requests.size > 1 && options.outboundBodyWriteStatus) return Number(options.outboundBodyWriteStatus);
587
+ const length = Math.min(Number(bufferLength), Number(options.bodyWriteChunkBytes || bufferLength));
588
+ const bytes = readBytes(buffer, length);
529
589
  body.writes.push(bytes);
530
590
  writeU32(writtenOut, bytes.length);
531
591
  trace.push({ module: 'fastly_http_body', name: 'write', handle: Number(handle), bytes: bytes.length, end: Number(end) });
@@ -534,12 +594,28 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
534
594
  }
535
595
  };
536
596
 
597
+ const kvEvidence = options.conditionalKv ? require('./conditional-kv-host.js').attachConditionalKvHost(imports, {
598
+ view, writeU32, writeU64, readBytes, readUtf8, alloc, bodies, bodyBytes, trace,
599
+ now: () => monotonicMs, advance: ms => { monotonicMs += ms; }
600
+ }, options.conditionalKv) : undefined;
601
+
602
+ // Model invocation termination, including cancellation raised by a host call.
603
+ // Throwing stops the Wasm invocation; it never fabricates an effect result.
604
+ const checkActive = () => { if (options.signal && options.signal.aborted) throw options.signal.reason || new Error('Request cancelled.'); };
605
+ checkActive();
606
+ if (options.signal) for (const namespace of Object.values(imports)) {
607
+ for (const [name, hostcall] of Object.entries(namespace)) if (typeof hostcall === 'function') namespace[name] = (...args) => {
608
+ checkActive(); const result = hostcall(...args); checkActive(); return result;
609
+ };
610
+ }
537
611
  const module = new WebAssembly.Module(wasm);
538
612
  instance = new WebAssembly.Instance(module, imports);
539
613
  if (typeof instance.exports._start !== 'function') {
540
614
  throw new FastlyNativePlatformCapabilitiesMockError('Pass98 native module is missing _start.', 'PULSE_FASTLY_NATIVE_PLATFORM_CAPABILITIES_MOCK_START_MISSING');
541
615
  }
616
+ if (options.conditionalKv && options.conditionalKv.deadlineNs !== undefined) instance.exports.pulse_fastly_kv_request_deadline(BigInt(options.conditionalKv.deadlineNs));
542
617
  instance.exports._start();
618
+ for (const entry of trace) if (entry.url && privateUrls.has(entry.url)) entry.url = '[REDACTED]';
543
619
  const lastError = typeof instance.exports.pulse_fastly_last_error === 'function'
544
620
  ? Number(instance.exports.pulse_fastly_last_error())
545
621
  : undefined;
@@ -580,6 +656,7 @@ function executeFastlyNativePlatformCapabilities(input, options = {}) {
580
656
  logs: Object.freeze(logs),
581
657
  outboundRequests: Object.freeze(outboundRequests),
582
658
  stores: Object.freeze({ config: snapshotStores(configStores), secrets: Object.fromEntries([...secretStores].map(([name, values]) => [name, Object.fromEntries([...values].map(([key]) => [key, '[REDACTED]']))])), kv: snapshotStores(kvStores, true) }),
659
+ kvEvidence,
583
660
  instance
584
661
  });
585
662
  }
@@ -1,4 +1,5 @@
1
1
  'use strict';
2
+ const { normalizeFastlyS3 } = require('./s3.js');
2
3
 
3
4
  function providerConfigError(code, message, detail) {
4
5
  const error = new TypeError(message);
@@ -37,6 +38,7 @@ function normalizeFastlyProviderConfig(value) {
37
38
  configStore: String(bindings.configStore || 'pulse_config'),
38
39
  secretStore: String(bindings.secretStore || 'pulse_secrets'),
39
40
  kv: normalizeStringMap(bindings.kv, 'PULSE_FASTLY_KV_BINDINGS_INVALID'),
41
+ s3: normalizeFastlyS3(bindings.s3),
40
42
  backends: normalizeStringMap(bindings.backends, 'PULSE_FASTLY_BACKEND_BINDINGS_INVALID'),
41
43
  grip: Object.freeze({
42
44
  fanoutBackend: gripInput.fanoutBackend === undefined ? undefined : String(gripInput.fanoutBackend),
@@ -98,6 +100,7 @@ const FASTLY_PROJECT_CONFIG_REFERENCE = Object.freeze({
98
100
  })
99
101
  ]),
100
102
  fields: Object.freeze([
103
+ Object.freeze({ section: 'fastly', path: 'fastly.bindings.s3', type: 'Readonly<Record<string, S3Binding & { backend: string }>>', default: '`{}`', scope: 'Fastly Native S3', description: 'Maps logical names to fixed HTTPS endpoint, bucket, region, named static backend, accessKeyIdSecret, secretAccessKeySecret, optional sessionTokenSecret, maxTextBytes (1–32768, default 32768) and timeoutMs (1–30000, default 10000).', security: 'Credentials resolve through the configured Secret Store at execution. Dynamic backend authority and guest endpoint overrides are forbidden.' }),
101
104
  Object.freeze({ section: 'fastly', path: 'fastly.configStore', type: 'string', default: '`pulse_config`', scope: 'Fastly config capability', description: 'Fastly Config Store resource name.' }),
102
105
  Object.freeze({ section: 'fastly', path: 'fastly.secretStore', type: 'string', default: '`pulse_secrets`', scope: 'Fastly secret capability', description: 'Fastly Secret Store resource name.', security: 'The name is build metadata; secret values are never embedded by this option.' }),
103
106
  Object.freeze({ section: 'fastly', path: 'fastly.kv', type: 'Readonly<Record<string, string>>', default: '`{}`', scope: 'Fastly KV capability', description: 'Maps logical ctx.kv names to Fastly KV Store resource names.', diagnostics: Object.freeze(['PULSE_FASTLY_KV_BINDINGS_INVALID']) }),
@@ -169,6 +169,8 @@ function createDriver() {
169
169
  runtimeClass: 'native',
170
170
  status: 'supported',
171
171
  capabilities: Object.freeze([
172
+ 's3.head', 's3.getText',
173
+ 'kv.getVersioned', 'kv.insertIfAbsent', 'kv.compareAndSwap',
172
174
  'jwt.verify',
173
175
  'jwt.verify.hs256',
174
176
  'jwt.verify.es256',
@@ -221,7 +223,7 @@ function createDriver() {
221
223
  automaticFallback: false,
222
224
  crypto: defineCryptoTargetCapabilities({
223
225
  target: 'native',
224
- algorithms: [{
226
+ algorithms: [...['SHA-256', 'HMAC-SHA256'].map((algorithm) => ({ algorithm, realization: 'guest-source:pulse-hmac-as', implemented: true, status: 'implemented-o2' })), {
225
227
  algorithm: 'HS256',
226
228
  realization: 'guest-source:pulse-hmac-as',
227
229
  implemented: true,
@@ -240,6 +242,7 @@ function createDriver() {
240
242
  javascript: FASTLY_JAVASCRIPT_TARGET_DESCRIPTOR
241
243
  }),
242
244
  execute: fastlyRuntime.executeCanonicalProgram,
245
+ prepareNativeExecution: require('./native-execution.js').prepareFastlyNativeExecution,
243
246
  createLoweringPlan(metadata, providerConfig = {}) {
244
247
  return createFastlyLoweringPlan(metadata, providerConfig.bindings || {});
245
248
  },
@@ -0,0 +1,71 @@
1
+ 'use strict';
2
+
3
+ const { inspectFastlyCanonicalTarget } = require('../build/canonical-target.js');
4
+ const { executeFastlyNativePlatformCapabilities } = require('../testing/native-platform-capabilities-host.js');
5
+ const { createConditionalKvAuthority } = require('../testing/conditional-kv-host.js');
6
+
7
+ // Execute the provider artifact against the existing fixture ABI. This is local
8
+ // conformance evidence, never Viceroy or deployed Fastly acceptance.
9
+ function prepareFastlyNativeExecution(invocation) {
10
+ const bindings = invocation.providerConfig.bindings;
11
+ const { native } = inspectFastlyCanonicalTarget({
12
+ plan: invocation.applicationPlan,
13
+ providerConfig: invocation.providerConfig,
14
+ projectRoot: invocation.project.root,
15
+ profile: invocation.project.profile,
16
+ targetDescriptor: invocation.selectedTarget,
17
+ synchronizedPackages: invocation.synchronizedPackages,
18
+ realizationArtifacts: invocation.nativeArtifact.realizationArtifacts,
19
+ guestUnits: invocation.nativeArtifact.guestUnits,
20
+ nativeOptimization: invocation.optimization,
21
+ compileTimeoutMs: invocation.timeoutMs || undefined
22
+ });
23
+ const evidence = Object.freeze({
24
+ kind: 'fastly-native-fixture-abi',
25
+ provider: 'fastly',
26
+ target: 'fastly-compute-native',
27
+ wasmSha256: native.manifest.wasm.sha256,
28
+ providerRealityValidated: false,
29
+ effectObservations: 'unavailable'
30
+ });
31
+ return (options = {}) => {
32
+ if (options.liveFetch === true) throw new TypeError('Fastly Native fixture execution cannot perform live fetches.');
33
+ const kvStores = {};
34
+ for (const [logical, physical] of Object.entries(bindings.kv || {})) {
35
+ kvStores[physical] = { ...kvStores[physical], ...(options.kv && options.kv[logical] || {}) };
36
+ }
37
+ const authority = createConditionalKvAuthority();
38
+ for (const [store, values] of Object.entries(kvStores)) {
39
+ authority.stores.set(store, new Map());
40
+ for (const [key, value] of Object.entries(values)) {
41
+ authority.seed(store, key, Buffer.from(JSON.stringify({ __pulseKv: 1, value })));
42
+ }
43
+ }
44
+ const execution = executeFastlyNativePlatformCapabilities(native, {
45
+ request: options.request,
46
+ fixtures: options.fetches,
47
+ configStore: bindings.configStore,
48
+ config: options.config,
49
+ secretStore: bindings.secretStore,
50
+ secrets: options.secrets,
51
+ kvStores,
52
+ conditionalKv: { authority }
53
+ });
54
+ const contentType = execution.response.headers.find(([name]) => name.toLowerCase() === 'content-type');
55
+ const type = String(contentType && contentType[1] || '').toLowerCase();
56
+ return Object.freeze({
57
+ response: Object.freeze({
58
+ ...execution.response,
59
+ kind: type.includes('application/json') || type.includes('+json') ? 'json' : type.startsWith('text/') ? 'text' : 'response',
60
+ bodyClass: execution.response.origin === true ? 'opaque' : 'structured'
61
+ }),
62
+ // The fixture ABI has hostcall traces, not canonical continuation telemetry.
63
+ effectCount: null,
64
+ continuations: Object.freeze([]),
65
+ resolutionOrder: Object.freeze([]),
66
+ evidence
67
+ });
68
+ };
69
+ }
70
+
71
+ module.exports = { prepareFastlyNativeExecution };
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+ const { normalizeBinding, namePattern } = require('@pulse-compute/s3/provider');
3
+ function normalizeFastlyS3(input = {}) {
4
+ if (!input || typeof input !== 'object' || Array.isArray(input) || ![Object.prototype, null].includes(Object.getPrototypeOf(input))) throw new TypeError('Fastly S3 bindings must be a map.');
5
+ for (const key of Reflect.ownKeys(input)) {
6
+ const field = Object.getOwnPropertyDescriptor(input, key);
7
+ if (typeof key !== 'string' || !field.enumerable || !Object.hasOwn(field, 'value')) throw new TypeError('Fastly S3 bindings require data properties.');
8
+ }
9
+ return Object.freeze(Object.fromEntries(Object.entries(input).map(([name, value]) => {
10
+ if (!namePattern.test(name) || !value || typeof value !== 'object' || ![Object.prototype, null].includes(Object.getPrototypeOf(value))
11
+ || Reflect.ownKeys(value).some((key) => { const field = Object.getOwnPropertyDescriptor(value, key); return typeof key !== 'string' || !field.enumerable || !Object.hasOwn(field, 'value'); })) throw new TypeError('Fastly S3 binding is invalid.');
12
+ const { backend, ...binding } = value;
13
+ if (typeof backend !== 'string' || !namePattern.test(backend)) throw new TypeError('Fastly S3 requires a named static backend.');
14
+ return [name, Object.freeze({ ...normalizeBinding(binding), backend })];
15
+ })));
16
+ }
17
+ function resolveFastlyS3(effects, input) {
18
+ const configured = normalizeFastlyS3(input);
19
+ return Object.freeze(effects.flatMap((effect, index) => {
20
+ if (!['s3.head', 's3.getText', 's3.putText'].includes(effect.kind)) return [];
21
+ const name = effect.resource && effect.resource.binding;
22
+ if (!Object.hasOwn(configured, name)) throw new TypeError('The required Fastly S3 binding is missing.');
23
+ return [Object.freeze({ index, ...configured[name], contentType: effect.operation === 'putText' ? require('@pulse-compute/s3/provider').normalizePutOptions({ contentType: effect.payload && effect.payload.contentType }).contentType : '' })];
24
+ }));
25
+ }
26
+ module.exports = { normalizeFastlyS3, resolveFastlyS3 };