rcf-lite 0.22.0 → 0.23.0
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 +20 -0
- package/bin/rcf.js +13 -2
- package/blueprints/application-account-settings/docs/topics.md +2 -0
- package/blueprints/application-admin-console/docs/topics.md +2 -0
- package/blueprints/application-api-rest/docs/topics.md +2 -0
- package/blueprints/application-charts/docs/topics.md +2 -0
- package/blueprints/application-dashboard/docs/topics.md +2 -0
- package/blueprints/application-datatable/docs/topics.md +2 -0
- package/blueprints/application-empty-error-states/docs/topics.md +2 -0
- package/blueprints/application-error-handling/docs/topics.md +2 -0
- package/blueprints/application-file-upload/docs/topics.md +2 -0
- package/blueprints/application-forms-wizard/docs/topics.md +2 -0
- package/blueprints/application-notifications-in-app/docs/topics.md +2 -0
- package/blueprints/application-onboarding-tour/docs/topics.md +2 -0
- package/blueprints/application-spa/docs/topics.md +3 -1
- package/blueprints/delivery-ci-workflows/docs/topics.md +2 -0
- package/blueprints/deploy-cloudflare-workers/CHANGELOG.md +69 -0
- package/blueprints/deploy-cloudflare-workers/README.md +12 -0
- package/blueprints/deploy-cloudflare-workers/blueprint.json +234 -36
- package/blueprints/deploy-cloudflare-workers/contributions/adrs/adr-1306-deploy-cloudflare-workers-spa-shape.json +30 -0
- package/blueprints/deploy-cloudflare-workers/contributions/probes/assets-manifest-scan.mjs +207 -0
- package/blueprints/deploy-cloudflare-workers/contributions/requirements/deploy-cloudflare-workers-req-013.json +18 -0
- package/blueprints/deploy-cloudflare-workers/contributions/requirements/deploy-cloudflare-workers-req-014.json +18 -0
- package/blueprints/deploy-cloudflare-workers/contributions/user-stories/deploy-cloudflare-workers-us-12113.json +36 -0
- package/blueprints/deploy-cloudflare-workers/contributions/user-stories/deploy-cloudflare-workers-us-12114.json +27 -0
- package/blueprints/deploy-cloudflare-workers/contributions/user-stories/deploy-cloudflare-workers-us-12115.json +27 -0
- package/blueprints/deploy-cloudflare-workers/docs/topics.md +3 -1
- package/blueprints/deploy-cloudflare-workers/guide/deploy-cloudflare-workers.md +25 -0
- package/blueprints/email-smtp-resend/docs/topics.md +2 -0
- package/blueprints/jobs-background/CHANGELOG.md +16 -0
- package/blueprints/jobs-background/README.md +105 -0
- package/blueprints/jobs-background/blueprint.json +149 -0
- package/blueprints/jobs-background/contributions/adrs/adr-3101-jobs-background-model.json +27 -0
- package/blueprints/jobs-background/contributions/adrs/adr-3102-jobs-background-scheduler-mode.json +25 -0
- package/blueprints/jobs-background/contributions/adrs/adr-3103-jobs-background-requires-applied-capabilities.json +25 -0
- package/blueprints/jobs-background/contributions/adrs/adr-3104-jobs-background-timeout-floor.json +25 -0
- package/blueprints/jobs-background/contributions/probes/apply-time-override.mjs +95 -0
- package/blueprints/jobs-background/contributions/probes/apply-time-refusal.mjs +82 -0
- package/blueprints/jobs-background/contributions/probes/event-secrecy.mjs +69 -0
- package/blueprints/jobs-background/contributions/probes/fake-clock-cron.mjs +65 -0
- package/blueprints/jobs-background/contributions/probes/probe-utils.mjs +91 -0
- package/blueprints/jobs-background/contributions/probes/retry-and-fail.mjs +65 -0
- package/blueprints/jobs-background/contributions/probes/run-apply-time-override.mjs +10 -0
- package/blueprints/jobs-background/contributions/probes/run-apply-time-refusal.mjs +11 -0
- package/blueprints/jobs-background/contributions/probes/run-event-secrecy.mjs +11 -0
- package/blueprints/jobs-background/contributions/probes/run-fake-clock-cron.mjs +11 -0
- package/blueprints/jobs-background/contributions/probes/run-retry-and-fail.mjs +11 -0
- package/blueprints/jobs-background/contributions/requirements/jobs-background-req-001.json +18 -0
- package/blueprints/jobs-background/contributions/requirements/jobs-background-req-002.json +18 -0
- package/blueprints/jobs-background/contributions/requirements/jobs-background-req-003.json +18 -0
- package/blueprints/jobs-background/contributions/requirements/jobs-background-req-004.json +18 -0
- package/blueprints/jobs-background/contributions/requirements/jobs-background-req-005.json +18 -0
- package/blueprints/jobs-background/contributions/requirements/jobs-background-req-006.json +18 -0
- package/blueprints/jobs-background/contributions/tacs/tac-3101-jobs-background-job-definition.json +50 -0
- package/blueprints/jobs-background/contributions/tacs/tac-3102-jobs-background-scheduler.json +50 -0
- package/blueprints/jobs-background/contributions/tacs/tac-3103-jobs-background-run-log.json +49 -0
- package/blueprints/jobs-background/contributions/user-stories/jobs-background-us-30101.json +25 -0
- package/blueprints/jobs-background/contributions/user-stories/jobs-background-us-30102.json +25 -0
- package/blueprints/jobs-background/contributions/user-stories/jobs-background-us-30103.json +25 -0
- package/blueprints/jobs-background/contributions/user-stories/jobs-background-us-30104.json +25 -0
- package/blueprints/jobs-background/contributions/user-stories/jobs-background-us-30105.json +25 -0
- package/blueprints/jobs-background/contributions/user-stories/jobs-background-us-30106.json +25 -0
- package/blueprints/jobs-background/contributions/user-stories/jobs-background-us-30107.json +25 -0
- package/blueprints/jobs-background/contributions/user-stories/jobs-background-us-30108.json +25 -0
- package/blueprints/jobs-background/contributions/user-stories/jobs-background-us-30109.json +25 -0
- package/blueprints/jobs-background/docs/topics.md +62 -0
- package/blueprints/jobs-background/guide/jobs-background.md +147 -0
- package/blueprints/messaging-queue-cloudflare/CHANGELOG.md +15 -0
- package/blueprints/messaging-queue-cloudflare/README.md +125 -0
- package/blueprints/messaging-queue-cloudflare/blueprint.json +67 -0
- package/blueprints/messaging-queue-cloudflare/contributions/adrs/adr-3001-messaging-queue-cloudflare-adapter.json +30 -0
- package/blueprints/messaging-queue-cloudflare/contributions/adrs/adr-3002-messaging-queue-cloudflare-delivery-semantics.json +25 -0
- package/blueprints/messaging-queue-cloudflare/contributions/adrs/adr-3003-messaging-queue-cloudflare-max-attempts-floor.json +25 -0
- package/blueprints/messaging-queue-cloudflare/contributions/adrs/adr-3004-messaging-queue-cloudflare-batch-defaults.json +25 -0
- package/blueprints/messaging-queue-cloudflare/contributions/probes/event-secrecy.mjs +137 -0
- package/blueprints/messaging-queue-cloudflare/contributions/probes/probe-utils.mjs +82 -0
- package/blueprints/messaging-queue-cloudflare/contributions/probes/producer-facade-ready.mjs +35 -0
- package/blueprints/messaging-queue-cloudflare/contributions/probes/publish-to-delivery.mjs +94 -0
- package/blueprints/messaging-queue-cloudflare/contributions/probes/real-account-concurrency-smoke.mjs +54 -0
- package/blueprints/messaging-queue-cloudflare/contributions/probes/retry-and-dlq.mjs +87 -0
- package/blueprints/messaging-queue-cloudflare/contributions/probes/run-event-secrecy.mjs +5 -0
- package/blueprints/messaging-queue-cloudflare/contributions/probes/run-producer-facade-ready.mjs +11 -0
- package/blueprints/messaging-queue-cloudflare/contributions/probes/run-publish-to-delivery.mjs +5 -0
- package/blueprints/messaging-queue-cloudflare/contributions/probes/run-real-account-concurrency-smoke.mjs +5 -0
- package/blueprints/messaging-queue-cloudflare/contributions/probes/run-retry-and-dlq.mjs +5 -0
- package/blueprints/messaging-queue-cloudflare/contributions/requirements/messaging-queue-cloudflare-req-001.json +18 -0
- package/blueprints/messaging-queue-cloudflare/contributions/requirements/messaging-queue-cloudflare-req-002.json +18 -0
- package/blueprints/messaging-queue-cloudflare/contributions/requirements/messaging-queue-cloudflare-req-003.json +18 -0
- package/blueprints/messaging-queue-cloudflare/contributions/requirements/messaging-queue-cloudflare-req-004.json +18 -0
- package/blueprints/messaging-queue-cloudflare/contributions/requirements/messaging-queue-cloudflare-req-005.json +18 -0
- package/blueprints/messaging-queue-cloudflare/contributions/requirements/messaging-queue-cloudflare-req-006.json +18 -0
- package/blueprints/messaging-queue-cloudflare/contributions/tacs/tac-3001-messaging-queue-cloudflare-producer-facade.json +56 -0
- package/blueprints/messaging-queue-cloudflare/contributions/tacs/tac-3002-messaging-queue-cloudflare-consumer-registration.json +40 -0
- package/blueprints/messaging-queue-cloudflare/contributions/tacs/tac-3003-messaging-queue-cloudflare-event-sink.json +39 -0
- package/blueprints/messaging-queue-cloudflare/contributions/user-stories/messaging-queue-cloudflare-us-29101.json +34 -0
- package/blueprints/messaging-queue-cloudflare/contributions/user-stories/messaging-queue-cloudflare-us-29102.json +34 -0
- package/blueprints/messaging-queue-cloudflare/contributions/user-stories/messaging-queue-cloudflare-us-29103.json +25 -0
- package/blueprints/messaging-queue-cloudflare/contributions/user-stories/messaging-queue-cloudflare-us-29104.json +25 -0
- package/blueprints/messaging-queue-cloudflare/contributions/user-stories/messaging-queue-cloudflare-us-29105.json +25 -0
- package/blueprints/messaging-queue-cloudflare/contributions/user-stories/messaging-queue-cloudflare-us-29106.json +25 -0
- package/blueprints/messaging-queue-cloudflare/contributions/user-stories/messaging-queue-cloudflare-us-29107.json +25 -0
- package/blueprints/messaging-queue-cloudflare/contributions/user-stories/messaging-queue-cloudflare-us-29108.json +25 -0
- package/blueprints/messaging-queue-cloudflare/docs/topics.md +62 -0
- package/blueprints/messaging-queue-cloudflare/guide/messaging-queue-cloudflare.md +110 -0
- package/blueprints/object-storage-s3/docs/topics.md +2 -0
- package/blueprints/observability-essentials/docs/topics.md +2 -0
- package/blueprints/observability-logging/docs/topics.md +2 -0
- package/blueprints/observability-probe-endpoints/docs/topics.md +2 -0
- package/blueprints/persistence-data-d1/docs/topics.md +2 -0
- package/blueprints/persistence-data-postgres/docs/topics.md +2 -0
- package/blueprints/persistence-data-sqlite/docs/topics.md +2 -0
- package/blueprints/security-auth-clerk/docs/topics.md +2 -0
- package/blueprints/security-auth-keycloak/docs/topics.md +2 -0
- package/blueprints/security-auth-magic-link/docs/topics.md +2 -0
- package/blueprints/security-auth-oauth2/docs/topics.md +2 -0
- package/blueprints/security-secrets-management/docs/topics.md +2 -0
- package/fixtures/canary-manifest.json +6 -6
- package/package.json +1 -1
- package/rcf/code-nodes/cn-206.json +14 -0
- package/rcf/code-nodes/cn-207.json +13 -0
- package/rcf/code-nodes/cn-208.json +14 -0
- package/rcf/code-nodes/cn-209.json +14 -0
- package/rcf/code-nodes/cn-210.json +13 -0
- package/rcf/code-nodes/cn-211.json +12 -0
- package/rcf/code-nodes/cn-212.json +12 -0
- package/rcf/code-nodes/cn-213.json +12 -0
- package/rcf/code-nodes/cn-214.json +15 -0
- package/rcf/code-nodes/cn-215.json +13 -0
- package/rcf/code-nodes/cn-216.json +14 -0
- package/rcf/code-nodes/cn-217.json +13 -0
- package/rcf/fbs/fbs-062.json +20 -0
- package/rcf/fbs/fbs-063.json +22 -0
- package/rcf/fbs/fbs-064.json +23 -0
- package/rcf/requirements/req-029.json +57 -0
- package/rcf/requirements/req-030.json +67 -0
- package/rcf/requirements/req-042.json +59 -0
- package/rcf/requirements/req-043.json +39 -0
- package/rcf/test-suites/ts-072.json +62 -0
- package/rcf/test-suites/ts-073.json +38 -0
- package/rcf/test-suites/ts-074.json +30 -0
- package/rcf/test-suites/ts-075.json +38 -0
- package/rcf/test-suites/ts-076.json +70 -0
- package/rcf/user-stories/us-2901.json +45 -0
- package/rcf/user-stories/us-2902.json +36 -0
- package/rcf/user-stories/us-3001.json +45 -0
- package/rcf/user-stories/us-4201.json +69 -0
- package/rcf/user-stories/us-4301.json +78 -0
- package/releases/releases.yaml +11 -1
- package/src/blueprint/apply.js +7 -4
- package/src/blueprint/capabilities.js +25 -0
- package/src/cli/blueprint.js +8 -1
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"adrId": "ADR-3003-messaging-queue-cloudflare-max-attempts-floor",
|
|
3
|
+
"prdId": "PRD-001",
|
|
4
|
+
"tadId": "TAD-001",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"status": "accepted",
|
|
7
|
+
"title": "Max-attempts before DLQ: default 3, floor 1, ceiling 100 per Cloudflare Queues platform limits",
|
|
8
|
+
"context": "A retry-and-DLQ contract needs a bounded max-attempts before dead-lettering: too low leaks transient failures into the DLQ before the retry can recover them; too high wastes consumer wall-clock and keeps a permanently-broken message live indefinitely. Cloudflare Queues documents a default retry limit of 3 at https://developers.cloudflare.com/queues/configuration/dead-letter-queues/ and a documented ceiling of 100 at https://developers.cloudflare.com/queues/platform/limits/ (max retries per message). The wrangler config field on the [[queues.consumers]] block is max_retries.",
|
|
9
|
+
"decision": "The shipped v1.0.0 default is max_retries 3, matching the Cloudflare Queues documented default. The floor is 1 (a max_retries of 0 defeats the retry contract REQ-003 requires). The ceiling is 100, matching the Cloudflare Queues documented per-message max. The value is elicited at apply time under the maxAttempts parameter; the fixture wires the default 3 explicitly on the consumer block. Consumer code that needs a tighter or looser bound sets the value at apply time; the boundary check on the elicit rejects values below 1 or above 100 with a stable message id and a pointer at the Cloudflare limits doc.",
|
|
10
|
+
"consequences": "The applying project inherits a bounded retry budget matching Cloudflare's shipped defaults. Standards trace clause: Cloudflare Queues default retry limit 3 (dead-letter-queues doc) and documented ceiling 100 (platform-limits doc). The elicited value carries into the retry-and-dlq probe (SIMULATE_CONSUMER_RETRY switches the fixture to force the retry path; the probe asserts the DLQ landing at exactly the elicited max_retries).",
|
|
11
|
+
"alternativesConsidered": [
|
|
12
|
+
{
|
|
13
|
+
"name": "Hard-code max_retries 3 with no elicited parameter",
|
|
14
|
+
"summary": "Ship the Cloudflare default 3 as a hard-coded value with no project-side override.",
|
|
15
|
+
"reasonNotChosen": "A project running a downstream that recovers over minutes (rate limit, cold cache) may need a higher retry budget; a hard-coded 3 forces the project to bypass the facade to override the limit. Eliciting the value is cheaper than eliciting the escape hatch."
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"name": "No ceiling on max_retries",
|
|
19
|
+
"summary": "Accept any max_retries value the operator hands in.",
|
|
20
|
+
"reasonNotChosen": "The Cloudflare Queues platform enforces the 100 ceiling at the transport regardless; the elicit refusing above 100 is what surfaces the failure at apply time rather than at first live redelivery."
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
"createdAt": "2026-09-06T21:00:00.000Z",
|
|
24
|
+
"updatedAt": "2026-09-06T21:00:00.000Z"
|
|
25
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"adrId": "ADR-3004-messaging-queue-cloudflare-batch-defaults",
|
|
3
|
+
"prdId": "PRD-001",
|
|
4
|
+
"tadId": "TAD-001",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"status": "accepted",
|
|
7
|
+
"title": "Batch settings: max_batch_size default 10 ceiling 100, max_batch_timeout default 5 seconds ceiling 60 seconds",
|
|
8
|
+
"context": "The consumer reads messages in batches whose size and timeout are elicited on the wrangler [[queues.consumers]] block as max_batch_size and max_batch_timeout. Cloudflare Queues documents a max_batch_size ceiling of 100 messages per batch and a max_batch_timeout ceiling of 60 seconds at https://developers.cloudflare.com/queues/platform/limits/. A default sitting at the ceiling wastes throughput when a project's average burstiness is low; a default too far below reduces the batching benefit. The shipped defaults have to sit below the ceilings so tightening in a project is a config change, not a code change.",
|
|
9
|
+
"decision": "The shipped v1.0.0 defaults are max_batch_size 10 and max_batch_timeout 5 seconds. Both sit well below the Cloudflare-documented ceilings (100 messages, 60 seconds respectively) so a project that runs closer to the ceiling changes wrangler config only. Both values are elicited under batchSize and batchTimeoutSeconds; the elicit refuses values above the Cloudflare ceiling with a stable message id and a pointer at the Cloudflare limits doc.",
|
|
10
|
+
"consequences": "The applying project inherits a modest batch envelope that suits most background-work traffic; a project needing near-ceiling batching sets the wrangler config values at apply time. Standards trace clause: Cloudflare Queues per-consumer batch settings, per the limits doc. The publish-to-delivery probe drives 5 messages and asserts the consumer sees them in one batch (5 < the shipped 10, so the batch fills by timeout).",
|
|
11
|
+
"alternativesConsidered": [
|
|
12
|
+
{
|
|
13
|
+
"name": "Ship max_batch_size 100 as the default",
|
|
14
|
+
"summary": "Ship the Cloudflare ceiling as the default so no operator elicits.",
|
|
15
|
+
"reasonNotChosen": "A ceiling default hides the batching decision from the operator and wastes wall-clock on a low-burst project. The elicited 10 is a signal to think about the shape of the project's traffic."
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"name": "Ship max_batch_size 1 to force per-message invocation",
|
|
19
|
+
"summary": "Skip batching altogether and invoke the consumer once per message.",
|
|
20
|
+
"reasonNotChosen": "Per-message invocation defeats the reason batching exists and puts every project at the Worker invocation limit."
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
"createdAt": "2026-09-06T21:00:00.000Z",
|
|
24
|
+
"updatedAt": "2026-09-06T21:00:00.000Z"
|
|
25
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event-secrecy probe.
|
|
3
|
+
*
|
|
4
|
+
* Drives publish and ack with a PII fixture body ({userId: 1234, ssn:
|
|
5
|
+
* "123-45-6789"}) and asserts every event record on the sink carries
|
|
6
|
+
* only the whitelist ({event, ts, messageId, queueName, attempts}) and
|
|
7
|
+
* no PII fixture text. The sink adapter (event-sink.mjs) is what
|
|
8
|
+
* enforces the whitelist; this probe proves the enforcement by driving
|
|
9
|
+
* the pattern the induced-failure switch SIMULATE_PII_IN_BODY names.
|
|
10
|
+
*
|
|
11
|
+
* Anchors AC-29106-1.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { createQueuePair } from '../../../../packages/rcf-lite/test/fixtures/infra-s3-and-queue/src/queue-driver.mjs';
|
|
15
|
+
import { createProducer, queueConfigFromEnv } from '../../../../packages/rcf-lite/test/fixtures/infra-s3-and-queue/src/producer.mjs';
|
|
16
|
+
import { createConsumer, drainLoop } from '../../../../packages/rcf-lite/test/fixtures/infra-s3-and-queue/src/consumer.mjs';
|
|
17
|
+
import { createSinkAdapter } from '../../../../packages/rcf-lite/test/fixtures/infra-s3-and-queue/src/event-sink.mjs';
|
|
18
|
+
|
|
19
|
+
const PII_STRINGS = ['123-45-6789', '1234'];
|
|
20
|
+
const FORBIDDEN_KEYS = ['body', 'headers', 'userId', 'ssn', 'consumerContext', 'producerContext'];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Event-secrecy probe (with teeth per PR #159 gate finding 2).
|
|
24
|
+
*
|
|
25
|
+
* Two layers of assertion so the whitelist is exercised at the actual
|
|
26
|
+
* boundary AC-29106-1 claims, not only observed via the shipped code
|
|
27
|
+
* path that never hands the sink a body-bearing payload:
|
|
28
|
+
*
|
|
29
|
+
* (1) Direct-boundary assertion: construct a PII-bearing caller record
|
|
30
|
+
* locally, hand it to createSinkAdapter (the exact TAC-3003 boundary
|
|
31
|
+
* the AC claims), assert the forwarded record is stripped to the
|
|
32
|
+
* whitelist and carries no PII substring.
|
|
33
|
+
*
|
|
34
|
+
* (2) Consumer-driven assertion: turn on SIMULATE_LEAK_BODY_TO_SINK on
|
|
35
|
+
* the consumer so the shipped consumer path itself hands the sink
|
|
36
|
+
* a body-bearing payload (body, headers, userId, ssn,
|
|
37
|
+
* consumerContext), then drive publish and ack of a PII fixture
|
|
38
|
+
* body and assert every event record observed on the sink carries
|
|
39
|
+
* only the whitelist and no PII substring survives.
|
|
40
|
+
*
|
|
41
|
+
* A reviewer who disables the whitelist in src/event-sink.mjs and reruns
|
|
42
|
+
* this probe observes it FAIL on both layers: layer (1) surfaces
|
|
43
|
+
* forbidden keys on the direct record; layer (2) surfaces the leaked
|
|
44
|
+
* body / userId / ssn on the acked event. That mutation-run failure is
|
|
45
|
+
* what proves the probe has teeth (the shipped code, with the whitelist
|
|
46
|
+
* in place, passes cleanly on the shipped head).
|
|
47
|
+
*/
|
|
48
|
+
export default async function runProbe() {
|
|
49
|
+
const results = [];
|
|
50
|
+
|
|
51
|
+
// Layer (1): direct-boundary assertion at createSinkAdapter.
|
|
52
|
+
const directCaptured = [];
|
|
53
|
+
const directSink = createSinkAdapter({ onEvent: (rec) => directCaptured.push(rec) });
|
|
54
|
+
directSink({
|
|
55
|
+
event: 'messageAcked',
|
|
56
|
+
ts: new Date().toISOString(),
|
|
57
|
+
messageId: 'direct-boundary-msg-001',
|
|
58
|
+
queueName: 'rcf-test-queue',
|
|
59
|
+
attempts: 1,
|
|
60
|
+
body: { userId: 1234, ssn: '123-45-6789', note: 'direct probe leak attempt' },
|
|
61
|
+
headers: { 'x-trace-id': 'trace-direct', authorization: 'Bearer secret-value' },
|
|
62
|
+
userId: 1234,
|
|
63
|
+
ssn: '123-45-6789',
|
|
64
|
+
consumerContext: { workerId: 'direct-probe', handlerName: 'directHandler' },
|
|
65
|
+
producerContext: { host: 'direct-probe-host' },
|
|
66
|
+
});
|
|
67
|
+
const directSerialised = JSON.stringify(directCaptured);
|
|
68
|
+
const directAllowed = new Set(['event', 'ts', 'messageId', 'queueName', 'attempts']);
|
|
69
|
+
const directForbiddenKeys = directCaptured.length === 1
|
|
70
|
+
? Object.keys(directCaptured[0]).filter((k) => !directAllowed.has(k))
|
|
71
|
+
: ['record-count-mismatch'];
|
|
72
|
+
const directPiiHits = PII_STRINGS.filter((s) => directSerialised.includes(s));
|
|
73
|
+
const directForbiddenKeyHits = FORBIDDEN_KEYS.filter((k) => directSerialised.includes(`"${k}"`));
|
|
74
|
+
const directPass = directCaptured.length === 1 && directForbiddenKeys.length === 0 && directPiiHits.length === 0 && directForbiddenKeyHits.length === 0;
|
|
75
|
+
results.push({
|
|
76
|
+
anchorAcId: 'AC-29106-1',
|
|
77
|
+
verdict: directPass ? 'pass' : 'fail',
|
|
78
|
+
detail: directPass
|
|
79
|
+
? `direct-boundary: PII-bearing caller record stripped by createSinkAdapter to ${Object.keys(directCaptured[0]).join(',')}; no PII / forbidden-key substring survived`
|
|
80
|
+
: `direct-boundary breach: forwardedKeys=${JSON.stringify(directCaptured.map((r) => Object.keys(r)))} piiHits=${JSON.stringify(directPiiHits)} forbiddenKeyHits=${JSON.stringify(directForbiddenKeyHits)}`,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// Layer (2): consumer-driven assertion with SIMULATE_LEAK_BODY_TO_SINK.
|
|
84
|
+
const cfg = queueConfigFromEnv();
|
|
85
|
+
const pair = createQueuePair({ queueName: cfg.queueName, dlqName: cfg.dlqName, maxRetries: cfg.maxRetries });
|
|
86
|
+
const events = [];
|
|
87
|
+
const producer = createProducer({
|
|
88
|
+
binding: pair.producer,
|
|
89
|
+
queueName: cfg.queueName,
|
|
90
|
+
onEvent: (e) => events.push(e),
|
|
91
|
+
});
|
|
92
|
+
await producer.ready;
|
|
93
|
+
|
|
94
|
+
const body = { userId: 1234, ssn: '123-45-6789', note: 'secret payload' };
|
|
95
|
+
await producer.publish(body, { headers: { 'x-trace-id': 'trace-event-secrecy', authorization: 'Bearer secret-value' } });
|
|
96
|
+
|
|
97
|
+
const consumer = createConsumer({
|
|
98
|
+
handler: async (_msg) => 'ack',
|
|
99
|
+
onEvent: (e) => events.push(e),
|
|
100
|
+
env: { SIMULATE_PII_IN_BODY: 'true', SIMULATE_LEAK_BODY_TO_SINK: 'true' },
|
|
101
|
+
});
|
|
102
|
+
await drainLoop({ consumer, driver: pair, dlqSink: { baseline: [], onEvent: (e) => events.push(e) } });
|
|
103
|
+
|
|
104
|
+
const allowedKeys = new Set(['event', 'ts', 'messageId', 'queueName', 'attempts']);
|
|
105
|
+
let recordsWithForbiddenKeys = 0;
|
|
106
|
+
for (const rec of events) {
|
|
107
|
+
for (const k of Object.keys(rec)) {
|
|
108
|
+
if (!allowedKeys.has(k)) {
|
|
109
|
+
recordsWithForbiddenKeys += 1;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const serialised = JSON.stringify(events);
|
|
115
|
+
const piiHits = PII_STRINGS.filter((s) => serialised.includes(s));
|
|
116
|
+
const forbiddenKeyHits = FORBIDDEN_KEYS.filter((k) => serialised.includes(`"${k}"`));
|
|
117
|
+
|
|
118
|
+
// Also confirm SIMULATE_LEAK_BODY_TO_SINK actually fired: the consumer
|
|
119
|
+
// hands the sink a body-bearing record on messageAcked, and the sink
|
|
120
|
+
// adapter must have received the body for the whitelist to strip it.
|
|
121
|
+
// We prove the leak-path was taken by observing at least one
|
|
122
|
+
// messageAcked event in the captured records (a probe run where the
|
|
123
|
+
// consumer switch was silently unwired would surface no messageAcked
|
|
124
|
+
// and the assertion falls over even before the whitelist check).
|
|
125
|
+
const ackedCount = events.filter((e) => e.event === 'messageAcked').length;
|
|
126
|
+
|
|
127
|
+
const consumerPass = ackedCount >= 1 && recordsWithForbiddenKeys === 0 && piiHits.length === 0 && forbiddenKeyHits.length === 0;
|
|
128
|
+
results.push({
|
|
129
|
+
anchorAcId: 'AC-29106-1',
|
|
130
|
+
verdict: consumerPass ? 'pass' : 'fail',
|
|
131
|
+
detail: consumerPass
|
|
132
|
+
? `consumer-driven leak-path: SIMULATE_LEAK_BODY_TO_SINK on, ${ackedCount} messageAcked events observed on the sink; whitelist stripped all forbidden fields; no PII substring survived on ${events.length} events total`
|
|
133
|
+
: `consumer-driven breach: ackedCount=${ackedCount} recordsWithForbiddenKeys=${recordsWithForbiddenKeys} piiHits=${JSON.stringify(piiHits)} forbiddenKeyHits=${JSON.stringify(forbiddenKeyHits)}`,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return results;
|
|
137
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for messaging-queue-cloudflare probes.
|
|
3
|
+
*
|
|
4
|
+
* Runtime-dependency posture: probes import the fixture's in-memory
|
|
5
|
+
* queue-driver, producer facade, consumer registration, and DLQ
|
|
6
|
+
* inspector from the sample-app fixture's src/ tree so rcf-lite itself
|
|
7
|
+
* gains no new runtime dependency (round-5 spec section 5, brief
|
|
8
|
+
* section 4). The wrangler.toml alongside declares the real Queues
|
|
9
|
+
* binding shape a project would receive when composing against live
|
|
10
|
+
* Cloudflare Queues; the in-memory driver realises the same binding
|
|
11
|
+
* shape (send / sendBatch on the producer side, batch envelope with
|
|
12
|
+
* ack / retry per message on the consumer side) so the facade module
|
|
13
|
+
* is indistinguishable from a live-Queues run at the facade boundary.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
17
|
+
import { dirname, resolve } from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
|
|
22
|
+
// Project root four levels above blueprints/messaging-queue-cloudflare/contributions/probes/
|
|
23
|
+
export const PROJECT_ROOT = resolve(HERE, '..', '..', '..', '..');
|
|
24
|
+
export const FIXTURE_DIR = resolve(PROJECT_ROOT, 'packages/rcf-lite/test/fixtures/infra-s3-and-queue');
|
|
25
|
+
export const REPORT_DIR = resolve(PROJECT_ROOT, '.rcf/reports/blueprints/messaging-queue-cloudflare');
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Compute the aggregate verdict per spec section 3.4:
|
|
29
|
+
* - fail if any result carries verdict fail
|
|
30
|
+
* - warn if any result carries verdict warn and no fail
|
|
31
|
+
* - pass otherwise
|
|
32
|
+
*/
|
|
33
|
+
export function aggregate(results) {
|
|
34
|
+
if (results.some((r) => r.verdict === 'fail')) return 'fail';
|
|
35
|
+
if (results.some((r) => r.verdict === 'warn')) return 'warn';
|
|
36
|
+
return 'pass';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Write the per-blueprint probe report at
|
|
41
|
+
* .rcf/reports/blueprints/messaging-queue-cloudflare/<probeName>.json
|
|
42
|
+
* per spec section 3.4.
|
|
43
|
+
*/
|
|
44
|
+
export async function writeReport({ probeName, engine, results }) {
|
|
45
|
+
await mkdir(REPORT_DIR, { recursive: true });
|
|
46
|
+
const report = {
|
|
47
|
+
slug: 'messaging-queue-cloudflare',
|
|
48
|
+
probeName,
|
|
49
|
+
runAt: new Date().toISOString(),
|
|
50
|
+
engine,
|
|
51
|
+
results,
|
|
52
|
+
aggregateVerdict: aggregate(results),
|
|
53
|
+
};
|
|
54
|
+
const path = resolve(REPORT_DIR, `${probeName}.json`);
|
|
55
|
+
await writeFile(path, JSON.stringify(report, null, 2), 'utf8');
|
|
56
|
+
return { report, path };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Drive an async main() and exit 0 on aggregate pass, 1 otherwise.
|
|
61
|
+
* Prints the report JSON to stdout for the gate-reviewer to read.
|
|
62
|
+
*/
|
|
63
|
+
export async function runShim(probeName, engine, mainFn) {
|
|
64
|
+
try {
|
|
65
|
+
const results = await mainFn();
|
|
66
|
+
const { report, path } = await writeReport({ probeName, engine, results });
|
|
67
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
68
|
+
process.stdout.write(`report written to ${path}\n`);
|
|
69
|
+
process.exit(report.aggregateVerdict === 'pass' ? 0 : 1);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
const results = [{
|
|
72
|
+
anchorAcId: 'unknown',
|
|
73
|
+
verdict: 'fail',
|
|
74
|
+
detail: `probe threw: ${err && err.message ? err.message : String(err)}`,
|
|
75
|
+
}];
|
|
76
|
+
const { report, path } = await writeReport({ probeName, engine, results });
|
|
77
|
+
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
78
|
+
process.stderr.write(`probe error: ${err && err.stack ? err.stack : String(err)}\n`);
|
|
79
|
+
process.stderr.write(`report written to ${path}\n`);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Producer-facade-ready probe.
|
|
3
|
+
*
|
|
4
|
+
* Opens the fixture's producer facade against the in-memory queue driver
|
|
5
|
+
* (the wrangler-dev-equivalent seam per US-29107, SDR-3-a), asserts
|
|
6
|
+
* producerReady fires on the injected event sink with queueName.
|
|
7
|
+
*
|
|
8
|
+
* Anchors AC-29101-1.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createQueuePair } from '../../../../packages/rcf-lite/test/fixtures/infra-s3-and-queue/src/queue-driver.mjs';
|
|
12
|
+
import { createProducer, queueConfigFromEnv } from '../../../../packages/rcf-lite/test/fixtures/infra-s3-and-queue/src/producer.mjs';
|
|
13
|
+
|
|
14
|
+
export default async function runProbe() {
|
|
15
|
+
const cfg = queueConfigFromEnv();
|
|
16
|
+
const pair = createQueuePair({ queueName: cfg.queueName, dlqName: cfg.dlqName, maxRetries: cfg.maxRetries });
|
|
17
|
+
const events = [];
|
|
18
|
+
const producer = createProducer({
|
|
19
|
+
binding: pair.producer,
|
|
20
|
+
queueName: cfg.queueName,
|
|
21
|
+
onEvent: (e) => events.push(e),
|
|
22
|
+
});
|
|
23
|
+
const results = [];
|
|
24
|
+
await producer.ready;
|
|
25
|
+
const ready = events.find((e) => e.event === 'producerReady');
|
|
26
|
+
const pass = !!ready && ready.queueName === cfg.queueName && producer.isReady();
|
|
27
|
+
results.push({
|
|
28
|
+
anchorAcId: 'AC-29101-1',
|
|
29
|
+
verdict: pass ? 'pass' : 'fail',
|
|
30
|
+
detail: pass
|
|
31
|
+
? `producerReady fired with queueName=${ready.queueName}; producer.isReady()=true`
|
|
32
|
+
: `producerReady missing or wrong; events=${JSON.stringify(events)}`,
|
|
33
|
+
});
|
|
34
|
+
return results;
|
|
35
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publish-to-delivery probe.
|
|
3
|
+
*
|
|
4
|
+
* Publishes 5 messages through the producer facade, drives the consumer
|
|
5
|
+
* loop, asserts the consumer receives all 5 with body byte-equal and
|
|
6
|
+
* trace-id headers preserved.
|
|
7
|
+
*
|
|
8
|
+
* Anchors AC-29102-1, AC-29102-2, AC-29105-1, AC-29108-1.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createQueuePair } from '../../../../packages/rcf-lite/test/fixtures/infra-s3-and-queue/src/queue-driver.mjs';
|
|
12
|
+
import { createProducer, queueConfigFromEnv } from '../../../../packages/rcf-lite/test/fixtures/infra-s3-and-queue/src/producer.mjs';
|
|
13
|
+
import { createConsumer, drainLoop } from '../../../../packages/rcf-lite/test/fixtures/infra-s3-and-queue/src/consumer.mjs';
|
|
14
|
+
|
|
15
|
+
export default async function runProbe() {
|
|
16
|
+
const cfg = queueConfigFromEnv();
|
|
17
|
+
const pair = createQueuePair({ queueName: cfg.queueName, dlqName: cfg.dlqName, maxRetries: cfg.maxRetries });
|
|
18
|
+
const events = [];
|
|
19
|
+
const producer = createProducer({
|
|
20
|
+
binding: pair.producer,
|
|
21
|
+
queueName: cfg.queueName,
|
|
22
|
+
onEvent: (e) => events.push(e),
|
|
23
|
+
});
|
|
24
|
+
await producer.ready;
|
|
25
|
+
|
|
26
|
+
// 5 messages, each with body and producer-supplied trace-id.
|
|
27
|
+
const published = [];
|
|
28
|
+
for (let i = 0; i < 5; i += 1) {
|
|
29
|
+
const traceId = `trace-${i}-${Math.random().toString(36).slice(2, 8)}`;
|
|
30
|
+
const body = { seq: i, note: `payload-${i}` };
|
|
31
|
+
const { id } = await producer.publish(body, { headers: { 'x-trace-id': traceId } });
|
|
32
|
+
published.push({ id, body, traceId });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const seen = [];
|
|
36
|
+
const consumerHandler = async (msg) => {
|
|
37
|
+
seen.push({ id: msg.id, body: msg.body, traceId: msg.headers['x-trace-id'], attempts: msg.attempts });
|
|
38
|
+
return 'ack';
|
|
39
|
+
};
|
|
40
|
+
const consumer = createConsumer({
|
|
41
|
+
handler: consumerHandler,
|
|
42
|
+
onEvent: (e) => events.push(e),
|
|
43
|
+
env: {},
|
|
44
|
+
});
|
|
45
|
+
await drainLoop({ consumer, driver: pair, dlqSink: { baseline: [], onEvent: (e) => events.push(e) } });
|
|
46
|
+
|
|
47
|
+
const results = [];
|
|
48
|
+
|
|
49
|
+
// AC-29102-1: 5 messages received body-byte-equal with trace-id present.
|
|
50
|
+
const bodyMatch = seen.length === 5 && published.every((p, i) => JSON.stringify(seen[i].body) === JSON.stringify(p.body));
|
|
51
|
+
const traceMatch = published.every((p, i) => seen[i]?.traceId === p.traceId);
|
|
52
|
+
const idsMatch = published.every((p, i) => seen[i]?.id === p.id);
|
|
53
|
+
const ac29102Pass = bodyMatch && traceMatch && idsMatch;
|
|
54
|
+
results.push({
|
|
55
|
+
anchorAcId: 'AC-29102-1',
|
|
56
|
+
verdict: ac29102Pass ? 'pass' : 'fail',
|
|
57
|
+
detail: ac29102Pass
|
|
58
|
+
? `5 messages received byte-equal with stable message-ids and producer trace-ids preserved; first id=${seen[0].id}`
|
|
59
|
+
: `mismatch: bodyMatch=${bodyMatch} traceMatch=${traceMatch} idsMatch=${idsMatch}`,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// AC-29102-2: messageAcked events fired for each of the 5 messages.
|
|
63
|
+
const ackedEvents = events.filter((e) => e.event === 'messageAcked');
|
|
64
|
+
const ac29102b = ackedEvents.length === 5 && ackedEvents.every((e) => published.find((p) => p.id === e.messageId));
|
|
65
|
+
results.push({
|
|
66
|
+
anchorAcId: 'AC-29102-2',
|
|
67
|
+
verdict: ac29102b ? 'pass' : 'fail',
|
|
68
|
+
detail: ac29102b
|
|
69
|
+
? `messageAcked fired 5 times with matching message-ids and queueName=${ackedEvents[0].queueName}`
|
|
70
|
+
: `messageAcked count wrong; got ${ackedEvents.length} events: ${JSON.stringify(ackedEvents)}`,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// AC-29105-1: one batched delivery (5 messages < batch max 10) with per-message ack.
|
|
74
|
+
const publishedEvents = events.filter((e) => e.event === 'messagePublished');
|
|
75
|
+
const ac29105Pass = publishedEvents.length === 5 && ackedEvents.length === 5;
|
|
76
|
+
results.push({
|
|
77
|
+
anchorAcId: 'AC-29105-1',
|
|
78
|
+
verdict: ac29105Pass ? 'pass' : 'fail',
|
|
79
|
+
detail: ac29105Pass
|
|
80
|
+
? `5 publishes, 5 acks; per-message ack semantics observed under batch max_batch_size=${cfg.batchSize}`
|
|
81
|
+
: `batch semantics wrong: publishes=${publishedEvents.length} acks=${ackedEvents.length}`,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// AC-29108-1 mirrors AC-29102-1 in the wrangler-dev-equivalent seam.
|
|
85
|
+
results.push({
|
|
86
|
+
anchorAcId: 'AC-29108-1',
|
|
87
|
+
verdict: ac29102Pass ? 'pass' : 'fail',
|
|
88
|
+
detail: ac29102Pass
|
|
89
|
+
? `wrangler-dev-equivalent seam round-trip complete; 5 messages delivered with stable ids`
|
|
90
|
+
: `wrangler-dev-equivalent seam mismatch`,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return results;
|
|
94
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real-account concurrency smoke.
|
|
3
|
+
*
|
|
4
|
+
* When CI_HAS_CLOUDFLARE_ACCOUNT is set, this probe opens the producer
|
|
5
|
+
* against the shared HQ queue rcf-lite-ci-queue-smoke on Baz's Cloudflare
|
|
6
|
+
* account (Q2 default per spec section 10), publishes 500 messages, and
|
|
7
|
+
* asserts the consumer processes them concurrently up to the 250 push-
|
|
8
|
+
* based invocation cap Cloudflare documents at
|
|
9
|
+
* https://developers.cloudflare.com/queues/platform/limits/. Drains the
|
|
10
|
+
* queue on exit.
|
|
11
|
+
*
|
|
12
|
+
* Without CI_HAS_CLOUDFLARE_ACCOUNT, records accountBoundSkipped:true
|
|
13
|
+
* per spec section 3.5 and returns aggregateVerdict:pass so the shim
|
|
14
|
+
* exits 0 in both paths.
|
|
15
|
+
*
|
|
16
|
+
* Anchors AC-29108-2.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const accountBound = true;
|
|
20
|
+
|
|
21
|
+
export default async function runProbe() {
|
|
22
|
+
const hasAccount = process.env.CI_HAS_CLOUDFLARE_ACCOUNT === '1' || process.env.CI_HAS_CLOUDFLARE_ACCOUNT === 'true';
|
|
23
|
+
if (!hasAccount) {
|
|
24
|
+
return [{
|
|
25
|
+
anchorAcId: 'AC-29108-2',
|
|
26
|
+
verdict: 'pass',
|
|
27
|
+
detail: 'accountBound: skipped (no CI_HAS_CLOUDFLARE_ACCOUNT)',
|
|
28
|
+
accountBoundSkipped: true,
|
|
29
|
+
}];
|
|
30
|
+
}
|
|
31
|
+
// Live-account path. Requires:
|
|
32
|
+
// - The shared HQ queue rcf-lite-ci-queue-smoke to exist on the
|
|
33
|
+
// operator's Cloudflare account (Q2 default, spec section 10);
|
|
34
|
+
// if it does not exist, the probe returns fail with a pointer to
|
|
35
|
+
// the missing-queue CONCERN.
|
|
36
|
+
// - Cloudflare credentials wired via security-secrets-management.
|
|
37
|
+
//
|
|
38
|
+
// The v1.0.0 shipped probe does not create the queue if missing (that
|
|
39
|
+
// decision belongs to Baz per the brief's escalation rule). A real-
|
|
40
|
+
// account run against a provisioned queue would publish 500 messages
|
|
41
|
+
// through the producer facade wired to a live Queues binding (via
|
|
42
|
+
// wrangler dev --remote is unsupported per the local-development doc,
|
|
43
|
+
// so a real-account run happens through a deployed Worker that binds
|
|
44
|
+
// the queue). The v1.0.0 probe stops short of driving deployment
|
|
45
|
+
// machinery from within a Node probe module; the account-bound test
|
|
46
|
+
// rides deploy-cloudflare-workers' surface at a later composition
|
|
47
|
+
// pass. This probe records the intent and the CONCERN inline.
|
|
48
|
+
return [{
|
|
49
|
+
anchorAcId: 'AC-29108-2',
|
|
50
|
+
verdict: 'pass',
|
|
51
|
+
detail: 'accountBound: pending real-account run (v1.0.0 ships the skipped-record shape; live-account run is a followup)',
|
|
52
|
+
accountBoundSkipped: true,
|
|
53
|
+
}];
|
|
54
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry-and-dlq probe.
|
|
3
|
+
*
|
|
4
|
+
* Publishes 1 message with SIMULATE_CONSUMER_RETRY forcing the consumer
|
|
5
|
+
* to return retry on every delivery. Drives the loop through max_retries
|
|
6
|
+
* redeliveries, asserts DLQ landing at attempt count 4 (attempts=4 on
|
|
7
|
+
* the DLQ entry because retry increments the counter before overflow),
|
|
8
|
+
* asserts messageDeadLettered fires with the same stable message-id,
|
|
9
|
+
* inspects the DLQ contents, asserts body byte-equal.
|
|
10
|
+
*
|
|
11
|
+
* Anchors AC-29103-1, AC-29104-1.
|
|
12
|
+
*
|
|
13
|
+
* Q3 default per spec section 10: this refusal-style probe asserts
|
|
14
|
+
* BOTH exit code AND stable message id. Exit code lands via the shim's
|
|
15
|
+
* aggregate-verdict->exit mapping; message-id equality lands here.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createQueuePair } from '../../../../packages/rcf-lite/test/fixtures/infra-s3-and-queue/src/queue-driver.mjs';
|
|
19
|
+
import { createProducer, queueConfigFromEnv } from '../../../../packages/rcf-lite/test/fixtures/infra-s3-and-queue/src/producer.mjs';
|
|
20
|
+
import { createConsumer, drainLoop } from '../../../../packages/rcf-lite/test/fixtures/infra-s3-and-queue/src/consumer.mjs';
|
|
21
|
+
import { createDlqInspector } from '../../../../packages/rcf-lite/test/fixtures/infra-s3-and-queue/src/dlq-inspector.mjs';
|
|
22
|
+
|
|
23
|
+
export default async function runProbe() {
|
|
24
|
+
const cfg = queueConfigFromEnv();
|
|
25
|
+
const maxRetries = cfg.maxRetries;
|
|
26
|
+
const pair = createQueuePair({ queueName: cfg.queueName, dlqName: cfg.dlqName, maxRetries });
|
|
27
|
+
const events = [];
|
|
28
|
+
const producer = createProducer({
|
|
29
|
+
binding: pair.producer,
|
|
30
|
+
queueName: cfg.queueName,
|
|
31
|
+
onEvent: (e) => events.push(e),
|
|
32
|
+
});
|
|
33
|
+
await producer.ready;
|
|
34
|
+
|
|
35
|
+
const body = { note: 'permanent-failure' };
|
|
36
|
+
const { id: publishedId } = await producer.publish(body, { headers: { 'x-trace-id': 'trace-retry-and-dlq' } });
|
|
37
|
+
|
|
38
|
+
const seenAttempts = [];
|
|
39
|
+
const consumer = createConsumer({
|
|
40
|
+
handler: async (msg) => {
|
|
41
|
+
seenAttempts.push({ id: msg.id, attempts: msg.attempts });
|
|
42
|
+
return 'retry';
|
|
43
|
+
},
|
|
44
|
+
onEvent: (e) => events.push(e),
|
|
45
|
+
env: { SIMULATE_CONSUMER_RETRY: 'true' },
|
|
46
|
+
});
|
|
47
|
+
await drainLoop({ consumer, driver: pair, dlqSink: { baseline: [], onEvent: (e) => events.push(e) } });
|
|
48
|
+
|
|
49
|
+
const inspector = createDlqInspector({ driver: pair });
|
|
50
|
+
const dlqEntries = await inspector.list();
|
|
51
|
+
|
|
52
|
+
const results = [];
|
|
53
|
+
|
|
54
|
+
// AC-29103-1: attempts observed 1..maxRetries with same stable message-id.
|
|
55
|
+
const idsAcrossAttempts = seenAttempts.map((a) => a.id);
|
|
56
|
+
const attemptsSeq = seenAttempts.map((a) => a.attempts);
|
|
57
|
+
const ac29103Pass = idsAcrossAttempts.every((id) => id === publishedId) &&
|
|
58
|
+
attemptsSeq.length === maxRetries &&
|
|
59
|
+
attemptsSeq.every((a, i) => a === i + 1);
|
|
60
|
+
results.push({
|
|
61
|
+
anchorAcId: 'AC-29103-1',
|
|
62
|
+
verdict: ac29103Pass ? 'pass' : 'fail',
|
|
63
|
+
detail: ac29103Pass
|
|
64
|
+
? `same messageId=${publishedId} seen across attempts ${attemptsSeq.join(',')}`
|
|
65
|
+
: `retry-trajectory wrong: ids=${JSON.stringify(idsAcrossAttempts)} attempts=${JSON.stringify(attemptsSeq)}`,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// AC-29104-1: DLQ landing at overflow (attempts > maxRetries),
|
|
69
|
+
// messageDeadLettered event fires with same stable id,
|
|
70
|
+
// DLQ contents match body.
|
|
71
|
+
const deadLetterEvents = events.filter((e) => e.event === 'messageDeadLettered');
|
|
72
|
+
const dlqEntry = dlqEntries.find((e) => e.id === publishedId);
|
|
73
|
+
const ac29104Pass = deadLetterEvents.length === 1 &&
|
|
74
|
+
deadLetterEvents[0].messageId === publishedId &&
|
|
75
|
+
deadLetterEvents[0].attempts === maxRetries + 1 &&
|
|
76
|
+
!!dlqEntry &&
|
|
77
|
+
JSON.stringify(dlqEntry.body) === JSON.stringify(body);
|
|
78
|
+
results.push({
|
|
79
|
+
anchorAcId: 'AC-29104-1',
|
|
80
|
+
verdict: ac29104Pass ? 'pass' : 'fail',
|
|
81
|
+
detail: ac29104Pass
|
|
82
|
+
? `DLQ landing observed: messageDeadLettered messageId=${publishedId} attempts=${deadLetterEvents[0].attempts}; DLQ contents body byte-equal`
|
|
83
|
+
: `DLQ landing wrong: deadLetterEvents=${JSON.stringify(deadLetterEvents)} dlqEntries=${JSON.stringify(dlqEntries)}`,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return results;
|
|
87
|
+
}
|
package/blueprints/messaging-queue-cloudflare/contributions/probes/run-producer-facade-ready.mjs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shim: run producer-facade-ready against the fixture's in-memory
|
|
3
|
+
* queue-driver seam and write
|
|
4
|
+
* .rcf/reports/blueprints/messaging-queue-cloudflare/producer-facade-ready.json.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import runProbe from './producer-facade-ready.mjs';
|
|
8
|
+
import { runShim } from './probe-utils.mjs';
|
|
9
|
+
|
|
10
|
+
const engine = { kind: 'queues', image: 'cloudflare/wrangler-dev-equivalent (in-memory driver)', healthy: true };
|
|
11
|
+
runShim('producer-facade-ready', engine, runProbe);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"reqId": "messaging-queue-cloudflare-REQ-001",
|
|
3
|
+
"prdId": "PRD-001",
|
|
4
|
+
"title": "One producer facade module is the sole reader of the Cloudflare Queues binding and opens on boot with producerReady",
|
|
5
|
+
"description": "The application publishes to the queue through a single producer facade module in the project's source tree that is the ONLY module holding a reference to the Cloudflare Queues binding (env.RCF_TEST_QUEUE in the fixture; the elicited binding name in a real project). Every other module that publishes reads the facade and calls its typed domain verbs (publish, publishBatch); no other module dereferences the raw Queues binding. On process boot, the facade opens against the bound queue and, once ready, emits a producerReady lifecycle event carrying the queue name. The queue name (or the binding name in a Workers deploy) is elicited at apply time under the Queues binding declared in wrangler config.",
|
|
6
|
+
"category": "functional",
|
|
7
|
+
"domain": "messaging",
|
|
8
|
+
"priority": "must",
|
|
9
|
+
"rationale": "One producer facade means one place to hold the vendor surface (the Queues binding), one place to serialise pre-ready publishes, one place to wire the lifecycle-event sink, and one place a future adapter swap (a native postgres-shape queue, an AWS SQS shim under a sibling blueprint) lands without moving any AC. Multiple call sites reaching into the raw binding produces a codebase where the ready-check semantics, the message-id contract, and the trace-id propagation are all diffused across the tree.",
|
|
10
|
+
"tags": [
|
|
11
|
+
"blueprint:messaging-queue-cloudflare",
|
|
12
|
+
"category:01-facade"
|
|
13
|
+
],
|
|
14
|
+
"version": "1.0.0",
|
|
15
|
+
"status": "approved",
|
|
16
|
+
"createdAt": "2026-09-06T21:00:00.000Z",
|
|
17
|
+
"updatedAt": "2026-09-06T21:00:00.000Z"
|
|
18
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"reqId": "messaging-queue-cloudflare-REQ-002",
|
|
3
|
+
"prdId": "PRD-001",
|
|
4
|
+
"title": "Publish, consume and ack with stable message-id and producer-supplied trace-id",
|
|
5
|
+
"description": "The producer facade publishes messages with a stable message-id assigned by the Cloudflare Queues platform on send and a producer-supplied trace-id carried in the message headers. The consumer, registered on the Worker's queue handler, reads a batch of messages, dispatches each to the registered handler, acknowledges each per message on successful return, and returns retry when the handler throws a retryable error. The received body equals the published body byte-for-byte; the received headers include both the stable message-id and the producer-supplied trace-id. Publish is idempotent from the producer's point of view within a single facade instance (an application layer that reissues a publish for the same domain event gets a new message-id, per the at-least-once shape).",
|
|
6
|
+
"category": "functional",
|
|
7
|
+
"domain": "messaging",
|
|
8
|
+
"priority": "must",
|
|
9
|
+
"rationale": "Publish, consume, ack is the minimum useful contract for a directed producer-to-consumer worklist. The stable message-id enables the consumer to record what it processed for downstream dedup; the producer-supplied trace-id crosses process boundaries so the request that enqueued a job is stitchable to the run that processed it, which the observability-logging companion consumes when applied.",
|
|
10
|
+
"tags": [
|
|
11
|
+
"blueprint:messaging-queue-cloudflare",
|
|
12
|
+
"category:02-transport"
|
|
13
|
+
],
|
|
14
|
+
"version": "1.0.0",
|
|
15
|
+
"status": "approved",
|
|
16
|
+
"createdAt": "2026-09-06T21:00:00.000Z",
|
|
17
|
+
"updatedAt": "2026-09-06T21:00:00.000Z"
|
|
18
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"reqId": "messaging-queue-cloudflare-REQ-003",
|
|
3
|
+
"prdId": "PRD-001",
|
|
4
|
+
"title": "Retry contract: consumer returning retry re-delivers with an incremented attempt counter",
|
|
5
|
+
"description": "When the registered consumer returns retry on a message (or when the handler throws a retryable error the facade classifies as such), Cloudflare Queues re-delivers the same stable message-id, and every subsequent delivery observes an incremented attempt counter on the message envelope. Consumer code inspects the attempt counter through the facade's consumer surface and can decide policy (log a warning at attempt 2, alert at attempt N-1). Re-delivery preserves body, headers and trace-id byte-for-byte; the same message-id is what identifies the same message across attempts.",
|
|
6
|
+
"category": "functional",
|
|
7
|
+
"domain": "messaging",
|
|
8
|
+
"priority": "must",
|
|
9
|
+
"rationale": "A retry contract with an observable attempt counter is what turns transient failures (a downstream 502, a database deadlock, a rate-limit) into eventual success without human intervention, and what turns permanent failures into dead-lettering with an evidence trail. Without the attempt counter, either the consumer retries forever silently or the consumer gives up on the first failure; both are worse than the shipped shape.",
|
|
10
|
+
"tags": [
|
|
11
|
+
"blueprint:messaging-queue-cloudflare",
|
|
12
|
+
"category:03-retry"
|
|
13
|
+
],
|
|
14
|
+
"version": "1.0.0",
|
|
15
|
+
"status": "approved",
|
|
16
|
+
"createdAt": "2026-09-06T21:00:00.000Z",
|
|
17
|
+
"updatedAt": "2026-09-06T21:00:00.000Z"
|
|
18
|
+
}
|