@hyodotdev/openiap-commerce-protocol 0.0.0-bootstrap.0 → 0.1.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.
Files changed (42) hide show
  1. package/CONVENTION.md +168 -0
  2. package/DESIGN.md +1056 -0
  3. package/README.md +227 -5
  4. package/SPEC.md +1471 -0
  5. package/conformance/index.d.ts +303 -0
  6. package/conformance/index.mjs +2126 -0
  7. package/conformance/mock-provider.mjs +491 -0
  8. package/examples/entitlement-granted-no-subscription.json +12 -0
  9. package/examples/entitlement-revoked.json +21 -0
  10. package/examples/provider-capabilities.json +209 -0
  11. package/examples/store-event-mapping.json +287 -0
  12. package/examples/subscription-canceled.json +22 -0
  13. package/examples/subscription-product-changed.json +30 -0
  14. package/examples/subscription-renewed.json +29 -0
  15. package/examples/verify-purchase-request.json +6 -0
  16. package/examples/verify-purchase-result.json +7 -0
  17. package/generated/bindings/graphql-operations.json +87 -0
  18. package/generated/bindings/http-binding.json +143 -0
  19. package/generated/bindings/introspection-signature.json +320 -0
  20. package/generated/bindings/operations-sdl.json +4 -0
  21. package/generated/bindings/operations.graphql +366 -0
  22. package/generated/commerce-protocol.graphql +1219 -0
  23. package/generated/openapi/commerce-protocol.openapi.json +1413 -0
  24. package/generated/schemas/commerce-event.schema.json +499 -0
  25. package/generated/schemas/commerce-protocol.bundle.schema.json +1576 -0
  26. package/generated/schemas/operations.schema.json +578 -0
  27. package/generated/schemas/primitives.schema.json +101 -0
  28. package/generated/schemas/provider-capabilities.schema.json +205 -0
  29. package/generated/schemas/store-event-mapping.schema.json +211 -0
  30. package/generated/vectors/lifecycle.json +908 -0
  31. package/generated/vectors/operations.json +1122 -0
  32. package/package.json +62 -12
  33. package/schema/01-primitives.graphql +102 -0
  34. package/schema/02-commerce-event.graphql +195 -0
  35. package/schema/03-provider-capabilities.graphql +139 -0
  36. package/schema/04-store-event-mapping.graphql +98 -0
  37. package/schema/05-operations.graphql +461 -0
  38. package/schema/06-compiler-vocabulary.graphql +139 -0
  39. package/schema/07-protocol-metadata.graphql +76 -0
  40. package/src/index.d.ts +63 -0
  41. package/src/index.mjs +121 -0
  42. package/vectors/signatures.json +139 -0
package/src/index.d.ts ADDED
@@ -0,0 +1,63 @@
1
+ export interface JsonSchema {
2
+ $id: string;
3
+ $schema: string;
4
+ title: string;
5
+ [key: string]: unknown;
6
+ }
7
+
8
+ export declare const primitivesSchema: JsonSchema;
9
+ export declare const bundleSchema: JsonSchema;
10
+ export declare const commerceEventSchema: JsonSchema;
11
+ export declare const providerCapabilitiesSchema: JsonSchema;
12
+ export declare const storeEventMappingSchema: JsonSchema;
13
+ export declare const operationsSchema: JsonSchema;
14
+ export declare const schemas: readonly JsonSchema[];
15
+
16
+ export interface HttpBindingOperation {
17
+ name: string;
18
+ kind: "query" | "mutation";
19
+ profile: string;
20
+ auth: "none" | "verification" | "server";
21
+ method: "GET" | "POST";
22
+ path: string;
23
+ successStatus: number;
24
+ idempotent: boolean;
25
+ errors: readonly string[];
26
+ input: string | null;
27
+ result: string;
28
+ }
29
+
30
+ export declare const HTTP_BINDING: Readonly<{
31
+ protocolVersion: string;
32
+ profiles: Readonly<Record<string, string>>;
33
+ bindings: Readonly<Record<string, string>>;
34
+ errorStatus: Readonly<Record<string, number>>;
35
+ errorResponse: string;
36
+ operations: readonly HttpBindingOperation[];
37
+ }>;
38
+
39
+ export declare const KNOWN_PROTOCOL_ERROR_CODES: readonly string[];
40
+ export declare const PROTOCOL_PROFILES: Readonly<Record<string, string>>;
41
+ export declare const PROTOCOL_BINDINGS: Readonly<Record<string, string>>;
42
+
43
+ export declare const COMMERCE_EVENT_VERSION: string;
44
+ export declare const KNOWN_COMMERCE_EVENT_TYPES: readonly string[];
45
+ export declare const KNOWN_STORES: readonly string[];
46
+ export declare const SUBSCRIPTION_STATES: readonly string[];
47
+ export declare const DATA_PROVENANCE: readonly string[];
48
+
49
+ export declare const EXTENSION_LIMITS: Readonly<{
50
+ maxEntries: number;
51
+ maxKeyLength: number;
52
+ maxValueLength: number;
53
+ }>;
54
+
55
+ export declare const WEBHOOK: Readonly<{
56
+ signatureHeader: string;
57
+ timestampHeader: string;
58
+ eventIdHeader: string;
59
+ deliveryIdHeader: string;
60
+ signaturePrefix: string;
61
+ contentType: string;
62
+ toleranceSeconds: number;
63
+ }>;
package/src/index.mjs ADDED
@@ -0,0 +1,121 @@
1
+ // Programmatic access to the specification artifacts.
2
+ //
3
+ // The generated JSON Schemas are the runtime artifacts. Every exported contract
4
+ // value is read from them rather than restated here; their GraphQL authoring
5
+ // source is commerce-protocol.graphql.
6
+
7
+ import { readFileSync } from "node:fs";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const load = (name) =>
11
+ JSON.parse(
12
+ readFileSync(
13
+ fileURLToPath(new URL(`../generated/schemas/${name}`, import.meta.url)),
14
+ "utf8",
15
+ ),
16
+ );
17
+
18
+ export const primitivesSchema = load("primitives.schema.json");
19
+ /**
20
+ * Every schema in one document with no external reference. Prefer this when
21
+ * validating: it needs no sibling file and no network access, so an
22
+ * implementation can validate offline and forever.
23
+ */
24
+ export const bundleSchema = load("commerce-protocol.bundle.schema.json");
25
+ export const commerceEventSchema = load("commerce-event.schema.json");
26
+ export const providerCapabilitiesSchema = load(
27
+ "provider-capabilities.schema.json",
28
+ );
29
+ export const storeEventMappingSchema = load("store-event-mapping.schema.json");
30
+ export const operationsSchema = load("operations.schema.json");
31
+
32
+ export const schemas = Object.freeze([
33
+ primitivesSchema,
34
+ commerceEventSchema,
35
+ providerCapabilitiesSchema,
36
+ storeEventMappingSchema,
37
+ operationsSchema,
38
+ ]);
39
+
40
+ const loadGenerated = (name) =>
41
+ JSON.parse(
42
+ readFileSync(
43
+ fileURLToPath(new URL(`../generated/${name}`, import.meta.url)),
44
+ "utf8",
45
+ ),
46
+ );
47
+
48
+ /** HTTP binding manifest for the operation surface, generated from the SDL. */
49
+ export const HTTP_BINDING = Object.freeze(
50
+ loadGenerated("bindings/http-binding.json"),
51
+ );
52
+
53
+ /** Protocol error codes this version names; the value space stays open. */
54
+ export const KNOWN_PROTOCOL_ERROR_CODES = Object.freeze([
55
+ ...operationsSchema.$defs.ProtocolError.properties.code.examples,
56
+ ]);
57
+
58
+ /** Operation profiles and transport bindings, keyed by name with versions. */
59
+ export const PROTOCOL_PROFILES = Object.freeze({ ...HTTP_BINDING.profiles });
60
+ export const PROTOCOL_BINDINGS = Object.freeze({ ...HTTP_BINDING.bindings });
61
+
62
+ const eventVersion = commerceEventSchema.$id.match(/\/(\d+\.\d+)\//)?.[1];
63
+ if (!eventVersion)
64
+ throw new Error("Commerce event schema has no MAJOR.MINOR ID");
65
+
66
+ /** Version of the event body, derived from the generated schema identifier. */
67
+ export const COMMERCE_EVENT_VERSION = eventVersion;
68
+
69
+ /** Event types named by this version; the schema permits future minor values. */
70
+ export const KNOWN_COMMERCE_EVENT_TYPES = Object.freeze([
71
+ ...commerceEventSchema.properties.eventType.examples,
72
+ ]);
73
+
74
+ /**
75
+ * The stores this version names. The value space is deliberately open — an
76
+ * implementation may emit a store that is not listed here, and a consumer must
77
+ * forward it rather than reject the event.
78
+ */
79
+ export const KNOWN_STORES = Object.freeze([
80
+ ...primitivesSchema.$defs.Store.examples,
81
+ ]);
82
+
83
+ export const SUBSCRIPTION_STATES = Object.freeze([
84
+ ...primitivesSchema.$defs.SubscriptionState.enum,
85
+ ]);
86
+
87
+ export const DATA_PROVENANCE = Object.freeze([
88
+ ...primitivesSchema.$defs.DataProvenance.enum,
89
+ ]);
90
+
91
+ /** Bounds on the `extensions` escape hatch, read from the schema. */
92
+ export const EXTENSION_LIMITS = Object.freeze({
93
+ maxEntries: primitivesSchema.$defs.Extensions.maxProperties,
94
+ maxKeyLength: primitivesSchema.$defs.Extensions.propertyNames.maxLength,
95
+ maxValueLength:
96
+ primitivesSchema.$defs.Extensions.additionalProperties.maxLength,
97
+ });
98
+
99
+ /**
100
+ * Transport constants a receiver needs, read from the published vectors so the
101
+ * package cannot disagree with the file an implementation actually tests
102
+ * against. Behaviour is specified in SPEC.md §9.4.
103
+ */
104
+ const signatureVectors = JSON.parse(
105
+ readFileSync(
106
+ fileURLToPath(new URL("../vectors/signatures.json", import.meta.url)),
107
+ "utf8",
108
+ ),
109
+ );
110
+
111
+ export const WEBHOOK = Object.freeze({
112
+ signatureHeader: signatureVectors.headers.signature,
113
+ timestampHeader: signatureVectors.headers.timestamp,
114
+ eventIdHeader: signatureVectors.headers.eventId,
115
+ deliveryIdHeader: signatureVectors.headers.deliveryId,
116
+ // Literals, not derived values: SPEC.md §9.4.2 and §9.4.1 name them, and
117
+ // test/vectors.test.mjs asserts every vector carries the prefix.
118
+ signaturePrefix: "v1=",
119
+ contentType: "application/json",
120
+ toleranceSeconds: signatureVectors.toleranceSeconds,
121
+ });
@@ -0,0 +1,139 @@
1
+ {
2
+ "$comment": "Test vectors for the OpenIAP Commerce Protocol outbound webhook signature. Any implementation, in any language, must reproduce every `expected` value and must reject every case in `rejections`. Signing input is the ASCII timestamp, one 0x2e byte, and the exact body bytes transmitted.",
3
+ "algorithm": "HMAC-SHA256, lowercase hex, prefixed `v1=`",
4
+ "toleranceSeconds": 300,
5
+ "headers": {
6
+ "signature": "openiap-signature",
7
+ "timestamp": "openiap-timestamp",
8
+ "eventId": "openiap-event-id",
9
+ "deliveryId": "openiap-delivery-id"
10
+ },
11
+ "cases": [
12
+ {
13
+ "name": "single-key",
14
+ "description": "The first attempt in the retry pair. retry-after-backoff uses the same body, eventId, and deliveryId with a fresh timestamp and signature.",
15
+ "secret": "whsec_0123456789abcdef0123456789abcdef",
16
+ "deliveryId": "delivery_retry_chain_01",
17
+ "timestamp": 1756300801,
18
+ "body": "{\"eventId\":\"evt_01J8Z4WQK7N2M0X5A9C3F6H8T1\",\"eventType\":\"subscription.renewed\",\"eventVersion\":\"1.0\",\"occurredAt\":1756300800000,\"processedAt\":1756300801420,\"store\":\"apple\",\"environment\":\"production\",\"projectId\":\"proj_7f3a9c\",\"applicationId\":\"app_2b8d41\",\"userId\":\"user_5e91a7\",\"productId\":\"premium.monthly\",\"transactionId\":\"2000000912345678\",\"originalTransactionId\":\"2000000811111111\",\"subscription\":{\"state\":\"Active\",\"productId\":\"premium.monthly\",\"expiresAt\":1758979200000,\"renewsAt\":1758979200000,\"willRenew\":true,\"active\":true},\"price\":{\"currency\":\"USD\",\"amountMicros\":9990000,\"provenance\":\"store\"},\"sourceStoreEventId\":\"8f3b1c2d-4e5a-6b7c-8d9e-0f1a2b3c4d5e\"}",
19
+ "expected": "v1=230e9384a09136b9ba877c6f50b4e79da24aee608b2af9b40ef288b1d03948e3"
20
+ },
21
+ {
22
+ "name": "retry-after-backoff",
23
+ "description": "A later attempt keeps the exact body, eventId, and deliveryId but uses a fresh current timestamp and recomputed signature.",
24
+ "secret": "whsec_0123456789abcdef0123456789abcdef",
25
+ "deliveryId": "delivery_retry_chain_01",
26
+ "timestamp": 1756301401,
27
+ "body": "{\"eventId\":\"evt_01J8Z4WQK7N2M0X5A9C3F6H8T1\",\"eventType\":\"subscription.renewed\",\"eventVersion\":\"1.0\",\"occurredAt\":1756300800000,\"processedAt\":1756300801420,\"store\":\"apple\",\"environment\":\"production\",\"projectId\":\"proj_7f3a9c\",\"applicationId\":\"app_2b8d41\",\"userId\":\"user_5e91a7\",\"productId\":\"premium.monthly\",\"transactionId\":\"2000000912345678\",\"originalTransactionId\":\"2000000811111111\",\"subscription\":{\"state\":\"Active\",\"productId\":\"premium.monthly\",\"expiresAt\":1758979200000,\"renewsAt\":1758979200000,\"willRenew\":true,\"active\":true},\"price\":{\"currency\":\"USD\",\"amountMicros\":9990000,\"provenance\":\"store\"},\"sourceStoreEventId\":\"8f3b1c2d-4e5a-6b7c-8d9e-0f1a2b3c4d5e\"}",
28
+ "expected": "v1=b840fe58b3a8e190eeef0612574125c282ce4384970832111de601678c170574"
29
+ },
30
+ {
31
+ "name": "raw-utf8-body",
32
+ "description": "Whitespace and the raw UTF-8 extension value are signed exactly. Parsing and reserializing this body changes the bytes and must not verify.",
33
+ "secret": "whsec_0123456789abcdef0123456789abcdef",
34
+ "timestamp": 1756300801,
35
+ "body": "{ \"eventId\": \"evt_raw_bytes\", \"eventType\": \"subscription.expired\", \"eventVersion\": \"1.0\", \"occurredAt\": 1756300800000, \"processedAt\": 1756300801420, \"store\": \"amazon\", \"environment\": \"production\", \"projectId\": \"proj_7f3a9c\", \"extensions\": { \"note\": \"café\" } }",
36
+ "expected": "v1=f3243f6851f3841fbbc95ef6f28e94be3bddb32facd86fbe65715f7480ec9b95"
37
+ },
38
+ {
39
+ "name": "during-rotation",
40
+ "description": "While a secret is rotating, the emitter signs with both keys and joins them with a comma. A receiver holding either key must accept. A receiver must compare against each presented value, never against the header as a whole.",
41
+ "secret": "whsec_0123456789abcdef0123456789abcdef",
42
+ "previousSecret": "whsec_fedcba9876543210fedcba9876543210",
43
+ "timestamp": 1756300801,
44
+ "body": "{\"eventId\":\"evt_01J8Z4WQK7N2M0X5A9C3F6H8T1\",\"eventType\":\"subscription.renewed\",\"eventVersion\":\"1.0\",\"occurredAt\":1756300800000,\"processedAt\":1756300801420,\"store\":\"apple\",\"environment\":\"production\",\"projectId\":\"proj_7f3a9c\",\"applicationId\":\"app_2b8d41\",\"userId\":\"user_5e91a7\",\"productId\":\"premium.monthly\",\"transactionId\":\"2000000912345678\",\"originalTransactionId\":\"2000000811111111\",\"subscription\":{\"state\":\"Active\",\"productId\":\"premium.monthly\",\"expiresAt\":1758979200000,\"renewsAt\":1758979200000,\"willRenew\":true,\"active\":true},\"price\":{\"currency\":\"USD\",\"amountMicros\":9990000,\"provenance\":\"store\"},\"sourceStoreEventId\":\"8f3b1c2d-4e5a-6b7c-8d9e-0f1a2b3c4d5e\"}",
45
+ "expected": "v1=230e9384a09136b9ba877c6f50b4e79da24aee608b2af9b40ef288b1d03948e3,v1=0f7dee4be14d34164590d0b40fbbb1dc01a22247554c03dfd89659478fc4ba9d",
46
+ "presentedHeader": "v1=230e9384a09136b9ba877c6f50b4e79da24aee608b2af9b40ef288b1d03948e3, v1=0f7dee4be14d34164590d0b40fbbb1dc01a22247554c03dfd89659478fc4ba9d"
47
+ },
48
+ {
49
+ "name": "minimal-event-omits-extensions",
50
+ "description": "Optional extensions may be omitted; this case pins a minimal event body.",
51
+ "secret": "whsec_0123456789abcdef0123456789abcdef",
52
+ "timestamp": 1756300801,
53
+ "body": "{\"eventId\":\"evt_min\",\"eventType\":\"subscription.expired\",\"eventVersion\":\"1.0\",\"occurredAt\":1756300800000,\"processedAt\":1756300800001,\"store\":\"amazon\",\"environment\":\"production\",\"projectId\":\"proj_7f3a9c\"}",
54
+ "expected": "v1=e39611a0dcaa7f1ceed5d41b39394122c7c0514cc91ceb86cffa0bcdea267731"
55
+ }
56
+ ],
57
+ "rejections": [
58
+ {
59
+ "name": "tampered-body",
60
+ "reason": "Body differs from the signed bytes by one character.",
61
+ "secret": "whsec_0123456789abcdef0123456789abcdef",
62
+ "timestamp": 1756300801,
63
+ "body": "{\"eventId\":\"evt_01J8Z4WQK7N2M0X5A9C3F6H8T1\",\"eventType\":\"subscription.renewed\",\"eventVersion\":\"1.0\",\"occurredAt\":1756300800000,\"processedAt\":1756300801420,\"store\":\"apple\",\"environment\":\"production\",\"projectId\":\"proj_7f3a9c\",\"applicationId\":\"app_2b8d41\",\"userId\":\"user_5e91a7\",\"productId\":\"premium.monthly\",\"transactionId\":\"2000000912345678\",\"originalTransactionId\":\"2000000811111111\",\"subscription\":{\"state\":\"Active\",\"productId\":\"premium.monthly\",\"expiresAt\":1758979200000,\"renewsAt\":1758979200000,\"willRenew\":true,\"active\":true},\"price\":{\"currency\":\"USD\",\"amountMicros\":1000,\"provenance\":\"store\"},\"sourceStoreEventId\":\"8f3b1c2d-4e5a-6b7c-8d9e-0f1a2b3c4d5e\"}",
64
+ "presentedSignature": "v1=230e9384a09136b9ba877c6f50b4e79da24aee608b2af9b40ef288b1d03948e3"
65
+ },
66
+ {
67
+ "name": "wrong-secret",
68
+ "reason": "Signature was produced with a key the receiver does not hold.",
69
+ "secret": "whsec_0123456789abcdef0123456789abcdef",
70
+ "timestamp": 1756300801,
71
+ "body": "{\"eventId\":\"evt_01J8Z4WQK7N2M0X5A9C3F6H8T1\",\"eventType\":\"subscription.renewed\",\"eventVersion\":\"1.0\",\"occurredAt\":1756300800000,\"processedAt\":1756300801420,\"store\":\"apple\",\"environment\":\"production\",\"projectId\":\"proj_7f3a9c\",\"applicationId\":\"app_2b8d41\",\"userId\":\"user_5e91a7\",\"productId\":\"premium.monthly\",\"transactionId\":\"2000000912345678\",\"originalTransactionId\":\"2000000811111111\",\"subscription\":{\"state\":\"Active\",\"productId\":\"premium.monthly\",\"expiresAt\":1758979200000,\"renewsAt\":1758979200000,\"willRenew\":true,\"active\":true},\"price\":{\"currency\":\"USD\",\"amountMicros\":9990000,\"provenance\":\"store\"},\"sourceStoreEventId\":\"8f3b1c2d-4e5a-6b7c-8d9e-0f1a2b3c4d5e\"}",
72
+ "presentedSignature": "v1=b45034a19e3c059eeec1e28a122f22410c2e038fbf2a886b19478a280a65b0fe"
73
+ },
74
+ {
75
+ "name": "timestamp-outside-tolerance",
76
+ "reason": "Signature is internally valid but older than the 300s tolerance, so it is a replay.",
77
+ "secret": "whsec_0123456789abcdef0123456789abcdef",
78
+ "timestamp": 1756300500,
79
+ "receiverNow": 1756300801,
80
+ "body": "{\"eventId\":\"evt_01J8Z4WQK7N2M0X5A9C3F6H8T1\",\"eventType\":\"subscription.renewed\",\"eventVersion\":\"1.0\",\"occurredAt\":1756300800000,\"processedAt\":1756300801420,\"store\":\"apple\",\"environment\":\"production\",\"projectId\":\"proj_7f3a9c\",\"applicationId\":\"app_2b8d41\",\"userId\":\"user_5e91a7\",\"productId\":\"premium.monthly\",\"transactionId\":\"2000000912345678\",\"originalTransactionId\":\"2000000811111111\",\"subscription\":{\"state\":\"Active\",\"productId\":\"premium.monthly\",\"expiresAt\":1758979200000,\"renewsAt\":1758979200000,\"willRenew\":true,\"active\":true},\"price\":{\"currency\":\"USD\",\"amountMicros\":9990000,\"provenance\":\"store\"},\"sourceStoreEventId\":\"8f3b1c2d-4e5a-6b7c-8d9e-0f1a2b3c4d5e\"}",
81
+ "presentedSignature": "v1=9f9969c260046b1eff88ecbf4792845115661a8682a1133e9de4fa44048942ad"
82
+ },
83
+ {
84
+ "name": "timestamp-not-in-signed-material",
85
+ "reason": "Signature covers only the body, so a captured body could be replayed under a fresh timestamp header.",
86
+ "secret": "whsec_0123456789abcdef0123456789abcdef",
87
+ "timestamp": 1756300801,
88
+ "body": "{\"eventId\":\"evt_01J8Z4WQK7N2M0X5A9C3F6H8T1\",\"eventType\":\"subscription.renewed\",\"eventVersion\":\"1.0\",\"occurredAt\":1756300800000,\"processedAt\":1756300801420,\"store\":\"apple\",\"environment\":\"production\",\"projectId\":\"proj_7f3a9c\",\"applicationId\":\"app_2b8d41\",\"userId\":\"user_5e91a7\",\"productId\":\"premium.monthly\",\"transactionId\":\"2000000912345678\",\"originalTransactionId\":\"2000000811111111\",\"subscription\":{\"state\":\"Active\",\"productId\":\"premium.monthly\",\"expiresAt\":1758979200000,\"renewsAt\":1758979200000,\"willRenew\":true,\"active\":true},\"price\":{\"currency\":\"USD\",\"amountMicros\":9990000,\"provenance\":\"store\"},\"sourceStoreEventId\":\"8f3b1c2d-4e5a-6b7c-8d9e-0f1a2b3c4d5e\"}",
89
+ "presentedSignature": "v1=81bcc31411153799434bd37ff71f041b76bf8038cf6be4a7b1117a2c5e7a86a6"
90
+ },
91
+ {
92
+ "name": "retry-reuses-first-signature",
93
+ "reason": "The second attempt has a fresh timestamp header but incorrectly reuses the first attempt's signature instead of signing that timestamp.",
94
+ "secret": "whsec_0123456789abcdef0123456789abcdef",
95
+ "timestamp": 1756301401,
96
+ "receiverNow": 1756301401,
97
+ "body": "{\"eventId\":\"evt_01J8Z4WQK7N2M0X5A9C3F6H8T1\",\"eventType\":\"subscription.renewed\",\"eventVersion\":\"1.0\",\"occurredAt\":1756300800000,\"processedAt\":1756300801420,\"store\":\"apple\",\"environment\":\"production\",\"projectId\":\"proj_7f3a9c\",\"applicationId\":\"app_2b8d41\",\"userId\":\"user_5e91a7\",\"productId\":\"premium.monthly\",\"transactionId\":\"2000000912345678\",\"originalTransactionId\":\"2000000811111111\",\"subscription\":{\"state\":\"Active\",\"productId\":\"premium.monthly\",\"expiresAt\":1758979200000,\"renewsAt\":1758979200000,\"willRenew\":true,\"active\":true},\"price\":{\"currency\":\"USD\",\"amountMicros\":9990000,\"provenance\":\"store\"},\"sourceStoreEventId\":\"8f3b1c2d-4e5a-6b7c-8d9e-0f1a2b3c4d5e\"}",
98
+ "presentedSignature": "v1=230e9384a09136b9ba877c6f50b4e79da24aee608b2af9b40ef288b1d03948e3"
99
+ },
100
+ {
101
+ "name": "garbage-appended-to-valid-signature",
102
+ "reason": "The presented value contains a valid signature as a prefix but is not equal to it. Rule 2 compares each whole comma-separated value; a substring or prefix match would accept this.",
103
+ "secret": "whsec_0123456789abcdef0123456789abcdef",
104
+ "timestamp": 1756300801,
105
+ "body": "{\"eventId\":\"evt_01J8Z4WQK7N2M0X5A9C3F6H8T1\",\"eventType\":\"subscription.renewed\",\"eventVersion\":\"1.0\",\"occurredAt\":1756300800000,\"processedAt\":1756300801420,\"store\":\"apple\",\"environment\":\"production\",\"projectId\":\"proj_7f3a9c\",\"applicationId\":\"app_2b8d41\",\"userId\":\"user_5e91a7\",\"productId\":\"premium.monthly\",\"transactionId\":\"2000000912345678\",\"originalTransactionId\":\"2000000811111111\",\"subscription\":{\"state\":\"Active\",\"productId\":\"premium.monthly\",\"expiresAt\":1758979200000,\"renewsAt\":1758979200000,\"willRenew\":true,\"active\":true},\"price\":{\"currency\":\"USD\",\"amountMicros\":9990000,\"provenance\":\"store\"},\"sourceStoreEventId\":\"8f3b1c2d-4e5a-6b7c-8d9e-0f1a2b3c4d5e\"}",
106
+ "presentedSignature": "v1=230e9384a09136b9ba877c6f50b4e79da24aee608b2af9b40ef288b1d03948e3ffff"
107
+ }
108
+ ],
109
+ "responseSemantics": {
110
+ "$comment": "SPEC.md 9.4.3. A conforming emitter maps every consumer response to exactly one action. 2xx is delivered; 408, 429, and 5xx are retried; 3xx and every other 4xx are permanent failures the emitter must not retry or follow; a timeout or connection error is retried.",
111
+ "connectionError": "retry",
112
+ "cases": [
113
+ { "status": 200, "action": "delivered" },
114
+ { "status": 202, "action": "delivered" },
115
+ { "status": 204, "action": "delivered" },
116
+ { "status": 299, "action": "delivered" },
117
+ { "status": 301, "action": "permanent-failure" },
118
+ { "status": 302, "action": "permanent-failure" },
119
+ { "status": 303, "action": "permanent-failure" },
120
+ { "status": 307, "action": "permanent-failure" },
121
+ { "status": 308, "action": "permanent-failure" },
122
+ { "status": 400, "action": "permanent-failure" },
123
+ { "status": 401, "action": "permanent-failure" },
124
+ { "status": 403, "action": "permanent-failure" },
125
+ { "status": 404, "action": "permanent-failure" },
126
+ { "status": 410, "action": "permanent-failure" },
127
+ { "status": 422, "action": "permanent-failure" },
128
+ { "status": 408, "action": "retry" },
129
+ { "status": 429, "action": "retry" },
130
+ { "status": 500, "action": "retry" },
131
+ { "status": 502, "action": "retry" },
132
+ { "status": 503, "action": "retry" },
133
+ { "status": 504, "action": "retry" },
134
+ { "status": 505, "action": "retry" },
135
+ { "status": 507, "action": "retry" },
136
+ { "status": 599, "action": "retry" }
137
+ ]
138
+ }
139
+ }