@aiacta-org/ai-citation-sdk 1.0.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/README.md ADDED
@@ -0,0 +1,243 @@
1
+ # @aiacta-org/ai-citation-sdk
2
+
3
+ > Webhook receiver SDK for AIACTA citation events — verifies signatures, handles idempotency, and provides ready-to-use Express middleware (Proposal 2, §3.4).
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@aiacta-org/ai-citation-sdk.svg)](https://www.npmjs.com/package/@aiacta-org/ai-citation-sdk)
6
+ [![PyPI version](https://img.shields.io/pypi/v/ai-citation-sdk.svg)](https://pypi.org/project/ai-citation-sdk/)
7
+ [![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](../../LICENSE)
8
+ [![AIACTA Spec](https://img.shields.io/badge/spec-AIACTA%2F1.0-orange.svg)](../../docs/proposals/proposal-2-citation-webhooks.md)
9
+
10
+ Available in **Node.js**, **Python**, and **Go**.
11
+
12
+ ---
13
+
14
+ ## What is this?
15
+
16
+ When an AI provider (Anthropic, OpenAI, Google, etc.) cites your content in a response, they POST a signed event to your `Citation-Webhook` endpoint. This SDK handles the security and plumbing so you can focus on what to do with the data.
17
+
18
+ It provides:
19
+ - **Signature verification** — HMAC-SHA256 with constant-time comparison (prevents timing attacks)
20
+ - **Replay attack prevention** — timestamps validated within a ±5-minute window
21
+ - **Idempotency** — duplicate events are safely ignored
22
+ - **Express middleware** — drop-in handler for Node.js servers
23
+ - **Retry schedule** — implements the §3.5 six-attempt delivery retry
24
+
25
+ ---
26
+
27
+ ## Install
28
+
29
+ **Node.js**
30
+ ```bash
31
+ npm install @aiacta-org/ai-citation-sdk
32
+ ```
33
+
34
+ **Python**
35
+ ```bash
36
+ pip install ai-citation-sdk
37
+ ```
38
+
39
+ **Go**
40
+ ```bash
41
+ go get github.com/aiacta-org/aiacta/ai-citation-sdk
42
+ ```
43
+
44
+ ---
45
+
46
+ ## Quick Start
47
+
48
+ ### Node.js — Express middleware (recommended)
49
+
50
+ ```javascript
51
+ const express = require('express');
52
+ const { createExpressMiddleware } = require('@aiacta-org/ai-citation-sdk');
53
+
54
+ const app = express();
55
+
56
+ // IMPORTANT: express.raw() must come before the middleware.
57
+ // Signature verification requires the raw bytes, not parsed JSON.
58
+ app.post(
59
+ '/webhooks/ai-citations',
60
+ express.raw({ type: 'application/json' }),
61
+ createExpressMiddleware({
62
+ secret: process.env.WEBHOOK_SECRET,
63
+
64
+ // Idempotency store — prevents processing the same event twice.
65
+ // Replace with your database in production.
66
+ store: {
67
+ exists: async (key) => await db.citations.exists({ idempotency_key: key }),
68
+ set: async (key) => await db.citations.markProcessed(key),
69
+ },
70
+
71
+ // Called once per unique, verified event
72
+ onEvent: async (event) => {
73
+ console.log('Citation received:', event.citation.url);
74
+ await db.citations.insert(event);
75
+ },
76
+ })
77
+ );
78
+
79
+ app.listen(3000);
80
+ ```
81
+
82
+ ### Node.js — manual verification
83
+
84
+ ```javascript
85
+ const { verifyWebhookSignature } = require('@aiacta-org/ai-citation-sdk');
86
+
87
+ app.post('/webhooks/ai-citations', express.raw({ type: 'application/json' }), async (req, res) => {
88
+ try {
89
+ const valid = verifyWebhookSignature(
90
+ req.body, // raw Buffer
91
+ req.headers['x-ai-webhook-timestamp'], // UNIX seconds string
92
+ req.headers['x-ai-webhook-sig'], // 'sha256=<hex>'
93
+ process.env.WEBHOOK_SECRET
94
+ );
95
+ if (!valid) return res.status(401).json({ error: 'Invalid signature' });
96
+ } catch (err) {
97
+ // Timestamp outside ±5 min window — possible replay attack
98
+ return res.status(400).json({ error: err.message });
99
+ }
100
+
101
+ res.status(200).json({ status: 'accepted' });
102
+
103
+ const event = JSON.parse(req.body.toString());
104
+ console.log('Provider:', event.provider);
105
+ console.log('Cited URL:', event.citation.url);
106
+ });
107
+ ```
108
+
109
+ ### Python
110
+
111
+ ```python
112
+ from aiacta import verify_webhook_signature
113
+
114
+ @app.route('/webhooks/ai-citations', methods=['POST'])
115
+ def citation_webhook():
116
+ raw_body = request.get_data()
117
+ timestamp = request.headers.get('X-AI-Webhook-Timestamp')
118
+ sig = request.headers.get('X-AI-Webhook-Sig')
119
+ secret = os.environ['WEBHOOK_SECRET']
120
+
121
+ try:
122
+ valid = verify_webhook_signature(raw_body, timestamp, sig, secret)
123
+ except ValueError as e:
124
+ return {'error': str(e)}, 400
125
+
126
+ if not valid:
127
+ return {'error': 'Invalid signature'}, 401
128
+
129
+ event = request.get_json(force=True)
130
+ print(f"Citation: {event['citation']['url']} via {event['provider']}")
131
+ return {'status': 'accepted'}, 200
132
+ ```
133
+
134
+ ### Go
135
+
136
+ ```go
137
+ import "github.com/aiacta-org/aiacta/ai-citation-sdk"
138
+
139
+ func webhookHandler(w http.ResponseWriter, r *http.Request) {
140
+ body, _ := io.ReadAll(r.Body)
141
+ timestamp := r.Header.Get("X-AI-Webhook-Timestamp")
142
+ sig := r.Header.Get("X-AI-Webhook-Sig")
143
+ secret := os.Getenv("WEBHOOK_SECRET")
144
+
145
+ ok, err := aiacta.VerifyWebhookSignature(body, timestamp, sig, secret)
146
+ if err != nil {
147
+ http.Error(w, err.Error(), http.StatusBadRequest)
148
+ return
149
+ }
150
+ if !ok {
151
+ http.Error(w, "Invalid signature", http.StatusUnauthorized)
152
+ return
153
+ }
154
+
155
+ w.WriteHeader(http.StatusOK)
156
+ // process event...
157
+ }
158
+ ```
159
+
160
+ ---
161
+
162
+ ## Citation Event Schema
163
+
164
+ ```json
165
+ {
166
+ "schema_version": "1.0",
167
+ "provider": "anthropic",
168
+ "event_type": "citation.generated",
169
+ "event_id": "evt_01J4KXQN2QP7HBW8FMYRC3T5VZ",
170
+ "idempotency_key": "idem_01J4KXQN_f3a9b2c1",
171
+ "timestamp": "2026-03-24T09:14:00Z",
172
+ "citation": {
173
+ "url": "https://yourdomain.com/articles/your-article",
174
+ "citation_type": "factual_source",
175
+ "context_summary": "Used to answer question about ...",
176
+ "query_category_l1": "technology",
177
+ "model": "claude-3-5-sonnet",
178
+ "user_country": "US"
179
+ },
180
+ "attribution": {
181
+ "display_type": "inline_link",
182
+ "user_interface": "chat"
183
+ }
184
+ }
185
+ ```
186
+
187
+ **Privacy note:** `user_country` is always country-level only. AI providers are prohibited from including user IDs or sub-country geodata (§3.3).
188
+
189
+ ---
190
+
191
+ ## Security Details
192
+
193
+ The signature covers: `timestamp + "." + raw_json_body`
194
+
195
+ ```
196
+ signature = HMAC-SHA256(shared_secret, signed_payload)
197
+ header = "sha256=" + hex(signature)
198
+ ```
199
+
200
+ The SDK uses `crypto.timingSafeEqual()` (Node.js), `hmac.compare_digest()` (Python), and `hmac.Equal()` (Go) to prevent timing oracle attacks.
201
+
202
+ ---
203
+
204
+ ## API Reference
205
+
206
+ ### Node.js
207
+
208
+ | Export | Description |
209
+ |--------|-------------|
210
+ | `verifyWebhookSignature(payload, timestamp, sigHeader, secret)` | Returns `boolean`. Throws `Error` if timestamp is outside ±300s. |
211
+ | `processEvent(event, store, onEvent)` | Processes with idempotency check. |
212
+ | `createExpressMiddleware({ secret, store, onEvent })` | Drop-in Express route handler. |
213
+
214
+ ### Python
215
+
216
+ | Function | Description |
217
+ |----------|-------------|
218
+ | `verify_webhook_signature(payload, timestamp, sig_header, secret)` | Returns `True` if valid. Raises `ValueError` on timestamp violation. |
219
+
220
+ ### Go
221
+
222
+ | Function | Description |
223
+ |----------|-------------|
224
+ | `VerifyWebhookSignature(rawBody, timestamp, sigHeader, secret)` | Returns `(bool, error)`. Error if timestamp outside window. |
225
+ | `ProcessEvent(events, store, handler)` | Idempotent batch processing. |
226
+ | `TruncateToMinute(t time.Time)` | Timestamp formatting per §3.2. |
227
+
228
+ ---
229
+
230
+ ## Related packages
231
+
232
+ | Package | Purpose |
233
+ |---------|---------|
234
+ | [`@aiacta-org/ai-attribution-lint`](https://www.npmjs.com/package/@aiacta-org/ai-attribution-lint) | Validate your `ai-attribution.txt` |
235
+ | [`@aiacta-org/crawl-manifest-client`](https://www.npmjs.com/package/@aiacta-org/crawl-manifest-client) | Query AI providers' crawl history |
236
+
237
+ ---
238
+
239
+ ## License & Copyright
240
+
241
+ Copyright © 2026 Eric Michel, PhD. Licensed under the [Apache License 2.0](../../LICENSE).
242
+
243
+ Part of the [AIACTA open standard](https://github.com/aiacta-org/aiacta).
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@aiacta-org/ai-citation-sdk",
3
+ "version": "1.0.0",
4
+ "description": "Webhook receiver SDK with signature verification, idempotency, and GDPR-compliant storage patterns (AIACTA Proposal 2 §3.4)",
5
+ "author": "Eric Michel",
6
+ "main": "./src/node/index.js",
7
+ "scripts": {
8
+ "test": "jest"
9
+ },
10
+ "dependencies": {
11
+ "express": "^5.2.1"
12
+ },
13
+ "devDependencies": {
14
+ "jest": "^29.0.0",
15
+ "supertest": "^7.0.0"
16
+ }
17
+ }
@@ -0,0 +1,134 @@
1
+ // Package aiacta provides the Go implementation of the AIACTA Citation SDK (§3.4).
2
+ //
3
+ // Implements:
4
+ // - VerifyWebhookSignature — HMAC-SHA256 request verification (§3.4A)
5
+ // - ProcessEvent — Idempotent citation event processing (§3.2)
6
+ // - WithRetry — Delivery retry schedule (§3.5)
7
+ // - TruncateToMinute — Event timestamp minute precision (§3.2)
8
+ package aiacta
9
+
10
+ import (
11
+ "crypto/hmac"
12
+ "crypto/sha256"
13
+ "encoding/hex"
14
+ "fmt"
15
+ "math"
16
+ "strconv"
17
+ "strings"
18
+ "time"
19
+ )
20
+
21
+ // TimestampToleranceSeconds is the maximum allowed clock skew for webhook
22
+ // signature verification, preventing replay attacks (§3.4A).
23
+ const TimestampToleranceSeconds = 300
24
+
25
+ // RetryDelaysSeconds defines the delivery retry schedule (§3.5).
26
+ // Attempt 1: immediate, Attempt 2: 30s, 3: 5m, 4: 30m, 5: 2h, 6: 12h.
27
+ var RetryDelaysSeconds = []int{0, 30, 300, 1800, 7200, 43200}
28
+
29
+ // CitationEvent represents a single AIACTA citation event payload (§3.2).
30
+ type CitationEvent struct {
31
+ SchemaVersion string `json:"schema_version"`
32
+ Provider string `json:"provider"`
33
+ EventType string `json:"event_type"`
34
+ EventID string `json:"event_id"`
35
+ IdempotencyKey string `json:"idempotency_key"`
36
+ Timestamp string `json:"timestamp"` // minute precision only (§3.2)
37
+ Citation Citation `json:"citation"`
38
+ Attribution Attribution `json:"attribution"`
39
+ }
40
+
41
+ // Citation holds the per-event citation details.
42
+ type Citation struct {
43
+ URL string `json:"url"`
44
+ CitationType string `json:"citation_type"`
45
+ ContextSummary string `json:"context_summary,omitempty"`
46
+ QueryCategoryL1 string `json:"query_category_l1,omitempty"`
47
+ QueryCategoryL2 string `json:"query_category_l2,omitempty"`
48
+ Model string `json:"model,omitempty"`
49
+ ResponseLocale string `json:"response_locale,omitempty"`
50
+ UserCountry string `json:"user_country,omitempty"` // country-level only (§3.3)
51
+ }
52
+
53
+ // Attribution describes how the citation was presented to the user.
54
+ type Attribution struct {
55
+ DisplayType string `json:"display_type"`
56
+ UserInterface string `json:"user_interface"`
57
+ }
58
+
59
+ // VerifyWebhookSignature validates an X-AI-Webhook-Sig header.
60
+ //
61
+ // The signature covers the string "${timestamp}.${rawBody}" using HMAC-SHA256
62
+ // with the shared secret issued at enrollment (§3.4A).
63
+ //
64
+ // Returns an error if the timestamp is outside the tolerance window
65
+ // (possible replay attack) or if the signature does not match.
66
+ func VerifyWebhookSignature(rawBody []byte, timestamp, sigHeader, secret string) (bool, error) {
67
+ ts, err := strconv.ParseInt(timestamp, 10, 64)
68
+ if err != nil {
69
+ return false, fmt.Errorf("invalid timestamp value %q: %w", timestamp, err)
70
+ }
71
+ if math.Abs(float64(time.Now().Unix()-ts)) > TimestampToleranceSeconds {
72
+ return false, fmt.Errorf("timestamp outside %ds tolerance window — possible replay attack", TimestampToleranceSeconds)
73
+ }
74
+
75
+ // signed = timestamp + "." + rawBody
76
+ signed := append([]byte(timestamp+"."), rawBody...)
77
+ mac := hmac.New(sha256.New, []byte(secret))
78
+ mac.Write(signed)
79
+ expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
80
+ received := strings.TrimPrefix(sigHeader, "sha256=")
81
+
82
+ // Constant-time comparison to prevent timing attacks
83
+ return hmac.Equal([]byte(expected), []byte("sha256="+received)), nil
84
+ }
85
+
86
+ // TruncateToMinute truncates an ISO 8601 timestamp to minute precision
87
+ // as required by §3.2 (prevents timing-attack re-identification of users).
88
+ func TruncateToMinute(t time.Time) string {
89
+ truncated := time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), 0, 0, time.UTC)
90
+ return truncated.Format("2006-01-02T15:04:00Z")
91
+ }
92
+
93
+ // IdempotencyStore is the interface that callers must implement to track
94
+ // processed event keys (§3.2: safe at-least-once reprocessing).
95
+ type IdempotencyStore interface {
96
+ // Has returns true if the key has already been processed.
97
+ Has(key string) bool
98
+ // Set marks the key as processed.
99
+ Set(key string)
100
+ }
101
+
102
+ // EventHandler is called once per unique citation event.
103
+ type EventHandler func(event CitationEvent) error
104
+
105
+ // ProcessEvent processes a single CitationEvent or a batch, skipping
106
+ // events whose idempotency_key has already been processed (§3.2).
107
+ func ProcessEvent(events []CitationEvent, store IdempotencyStore, handler EventHandler) error {
108
+ for _, e := range events {
109
+ if store.Has(e.IdempotencyKey) {
110
+ continue
111
+ }
112
+ if err := handler(e); err != nil {
113
+ return fmt.Errorf("handler error for event %s: %w", e.EventID, err)
114
+ }
115
+ store.Set(e.IdempotencyKey)
116
+ }
117
+ return nil
118
+ }
119
+
120
+ // WithRetry executes fn up to len(RetryDelaysSeconds) times using the
121
+ // AIACTA delivery retry schedule (§3.5). Returns an error if all attempts fail.
122
+ func WithRetry(fn func() error) error {
123
+ for attempt, delay := range RetryDelaysSeconds {
124
+ if delay > 0 {
125
+ time.Sleep(time.Duration(delay) * time.Second)
126
+ }
127
+ if err := fn(); err == nil {
128
+ return nil
129
+ } else if attempt == len(RetryDelaysSeconds)-1 {
130
+ return fmt.Errorf("dead-lettered after %d attempts: %w", len(RetryDelaysSeconds), err)
131
+ }
132
+ }
133
+ return nil
134
+ }
@@ -0,0 +1,87 @@
1
+ package aiacta_test
2
+
3
+ import (
4
+ "crypto/hmac"
5
+ "crypto/sha256"
6
+ "encoding/hex"
7
+ "fmt"
8
+ "testing"
9
+ "time"
10
+
11
+ "github.com/aiacta-org/aiacta/ai-citation-sdk"
12
+ )
13
+
14
+ func makeSignature(payload []byte, timestamp, secret string) string {
15
+ signed := append([]byte(timestamp+"."), payload...)
16
+ mac := hmac.New(sha256.New, []byte(secret))
17
+ mac.Write(signed)
18
+ return "sha256=" + hex.EncodeToString(mac.Sum(nil))
19
+ }
20
+
21
+ func TestVerifyWebhookSignature_Valid(t *testing.T) {
22
+ ts := fmt.Sprintf("%d", time.Now().Unix())
23
+ payload := []byte(`{"event_type":"citation.generated"}`)
24
+ sig := makeSignature(payload, ts, "test-secret")
25
+
26
+ ok, err := aiacta.VerifyWebhookSignature(payload, ts, sig, "test-secret")
27
+ if err != nil { t.Fatalf("unexpected error: %v", err) }
28
+ if !ok { t.Fatal("expected signature to be valid") }
29
+ }
30
+
31
+ func TestVerifyWebhookSignature_TamperedPayload(t *testing.T) {
32
+ ts := fmt.Sprintf("%d", time.Now().Unix())
33
+ sig := makeSignature([]byte("original"), ts, "secret")
34
+ ok, err := aiacta.VerifyWebhookSignature([]byte("tampered"), ts, sig, "secret")
35
+ if err != nil { t.Fatalf("unexpected error: %v", err) }
36
+ if ok { t.Fatal("expected tampered payload to fail verification") }
37
+ }
38
+
39
+ func TestVerifyWebhookSignature_StaleTimestamp(t *testing.T) {
40
+ oldTs := fmt.Sprintf("%d", time.Now().Unix()-400)
41
+ _, err := aiacta.VerifyWebhookSignature([]byte("{}"), oldTs, "sha256=abc", "secret")
42
+ if err == nil { t.Fatal("expected error for stale timestamp") }
43
+ }
44
+
45
+ func TestTruncateToMinute(t *testing.T) {
46
+ now := time.Date(2026, 3, 24, 9, 14, 37, 500_000_000, time.UTC)
47
+ result := aiacta.TruncateToMinute(now)
48
+ expected := "2026-03-24T09:14:00Z"
49
+ if result != expected {
50
+ t.Fatalf("got %s, want %s", result, expected)
51
+ }
52
+ }
53
+
54
+ type inMemoryStore struct { keys map[string]bool }
55
+ func (s *inMemoryStore) Has(k string) bool { return s.keys[k] }
56
+ func (s *inMemoryStore) Set(k string) { s.keys[k] = true }
57
+
58
+ func TestProcessEvent_Idempotency(t *testing.T) {
59
+ store := &inMemoryStore{keys: make(map[string]bool)}
60
+ called := 0
61
+ events := []aiacta.CitationEvent{
62
+ {IdempotencyKey: "idem_1", EventID: "evt_1"},
63
+ }
64
+ handler := func(e aiacta.CitationEvent) error { called++; return nil }
65
+
66
+ // Call 3 times — should only process once
67
+ for i := 0; i < 3; i++ {
68
+ if err := aiacta.ProcessEvent(events, store, handler); err != nil {
69
+ t.Fatalf("unexpected error: %v", err)
70
+ }
71
+ }
72
+ if called != 1 {
73
+ t.Fatalf("expected handler called once, got %d", called)
74
+ }
75
+ }
76
+
77
+ func TestRetryDelaysSchedule(t *testing.T) {
78
+ if len(aiacta.RetryDelaysSeconds) != 6 {
79
+ t.Fatalf("expected 6 retry attempts, got %d", len(aiacta.RetryDelaysSeconds))
80
+ }
81
+ if aiacta.RetryDelaysSeconds[0] != 0 {
82
+ t.Fatal("first attempt must be immediate")
83
+ }
84
+ if aiacta.RetryDelaysSeconds[5] != 43200 {
85
+ t.Fatalf("last delay must be 43200s (12h), got %d", aiacta.RetryDelaysSeconds[5])
86
+ }
87
+ }
package/src/go/go.mod ADDED
@@ -0,0 +1,5 @@
1
+ module github.com/aiacta-org/aiacta/ai-citation-sdk
2
+
3
+ go 1.21
4
+
5
+ // No external dependencies — standard library only.
@@ -0,0 +1,10 @@
1
+ /**
2
+ * ai-citation-sdk — Node.js
3
+ * Exports: verifyWebhookSignature, processEvent, createExpressMiddleware
4
+ */
5
+ 'use strict';
6
+ const { verifyWebhookSignature } = require('./signature');
7
+ const { processEvent } = require('./processor');
8
+ const { createExpressMiddleware }= require('./middleware');
9
+
10
+ module.exports = { verifyWebhookSignature, processEvent, createExpressMiddleware };
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Express middleware factory.
3
+ * Attaches signature verification and idempotency handling to a route.
4
+ * Usage:
5
+ * app.post('/webhooks/ai-citations', createExpressMiddleware({ secret, store, onEvent }));
6
+ */
7
+ 'use strict';
8
+ const { verifyWebhookSignature } = require('./signature');
9
+ const { processEvent } = require('./processor');
10
+
11
+ function createExpressMiddleware({ secret, store, onEvent }) {
12
+ return async (req, res) => {
13
+ const timestamp = req.headers['x-ai-webhook-timestamp'];
14
+ const sig = req.headers['x-ai-webhook-sig'];
15
+ // Body must be raw Buffer — use express.raw() before this middleware
16
+ try {
17
+ const valid = verifyWebhookSignature(req.body, timestamp, sig, secret);
18
+ if (!valid) return res.status(401).json({ error: 'Invalid signature' });
19
+ } catch (e) {
20
+ return res.status(400).json({ error: e.message });
21
+ }
22
+
23
+ const payload = JSON.parse(req.body.toString('utf-8'));
24
+ // Respond quickly — process asynchronously (§3.5)
25
+ res.status(200).json({ status: 'accepted' });
26
+ await processEvent(payload, {
27
+ isProcessed: (k) => store.exists(k),
28
+ markProcessed: (k) => store.set(k, true),
29
+ onEvent,
30
+ }).catch(err => console.error('[ai-citation-sdk] handler error:', err));
31
+ };
32
+ }
33
+
34
+ module.exports = { createExpressMiddleware };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Processes a parsed CitationEvent or CitationBatch.
3
+ * Handles idempotency check, normalization, and dispatches to user handler.
4
+ * GDPR: strips no fields beyond what the schema already omits (§3.3).
5
+ */
6
+ 'use strict';
7
+
8
+ /**
9
+ * @param {object} event Parsed JSON body (single event or batch)
10
+ * @param {object} opts
11
+ * @param {Function} opts.isProcessed async (idempotency_key) => boolean
12
+ * @param {Function} opts.markProcessed async (idempotency_key) => void
13
+ * @param {Function} opts.onEvent async (event) => void — your handler
14
+ */
15
+ async function processEvent(event, { isProcessed, markProcessed, onEvent }) {
16
+ // Flatten batch into individual events
17
+ const events = event.events ? event.events : [event];
18
+
19
+ for (const e of events) {
20
+ if (await isProcessed(e.idempotency_key)) {
21
+ continue; // safe at-least-once reprocessing (§3.2)
22
+ }
23
+ await onEvent(e);
24
+ await markProcessed(e.idempotency_key);
25
+ }
26
+ }
27
+
28
+ module.exports = { processEvent };
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Retry schedule constants and helper for outbound webhook delivery
3
+ * (relevant for AI provider implementations — §3.5).
4
+ */
5
+ 'use strict';
6
+
7
+ /** Delays in seconds matching the spec's retry schedule. */
8
+ const RETRY_DELAYS_SECONDS = [0, 30, 300, 1800, 7200, 43200];
9
+
10
+ /**
11
+ * Executes fn with the AIACTA retry schedule.
12
+ * @param {Function} fn Async function that throws on failure
13
+ */
14
+ async function withRetry(fn) {
15
+ for (let attempt = 0; attempt < RETRY_DELAYS_SECONDS.length; attempt++) {
16
+ if (attempt > 0) {
17
+ await new Promise(r => setTimeout(r, RETRY_DELAYS_SECONDS[attempt] * 1000));
18
+ }
19
+ try {
20
+ return await fn();
21
+ } catch (err) {
22
+ if (attempt === RETRY_DELAYS_SECONDS.length - 1) {
23
+ throw new Error(`Dead-lettered after ${RETRY_DELAYS_SECONDS.length} attempts: ${err.message}`);
24
+ }
25
+ }
26
+ }
27
+ }
28
+
29
+ module.exports = { withRetry, RETRY_DELAYS_SECONDS };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Cryptographic webhook signature verification (§3.4 — HMAC-SHA256).
3
+ * Implements the exact algorithm specified in the whitepaper.
4
+ */
5
+ 'use strict';
6
+ const crypto = require('crypto');
7
+
8
+ const TIMESTAMP_TOLERANCE_SECONDS = 300; // 5 minutes (§3.4)
9
+
10
+ /**
11
+ * @param {string|Buffer} payload Raw request body
12
+ * @param {string} timestamp Value of X-AI-Webhook-Timestamp header
13
+ * @param {string} sigHeader Value of X-AI-Webhook-Sig header (sha256=<hex>)
14
+ * @param {string} secret Shared HMAC secret issued at enrollment
15
+ * @returns {boolean}
16
+ * @throws {Error} if timestamp is outside tolerance window
17
+ */
18
+ function verifyWebhookSignature(payload, timestamp, sigHeader, secret) {
19
+ const now = Math.floor(Date.now() / 1000);
20
+ if (Math.abs(now - parseInt(timestamp, 10)) > TIMESTAMP_TOLERANCE_SECONDS) {
21
+ throw new Error('Timestamp outside tolerance window — possible replay attack');
22
+ }
23
+ const signedPayload = `${timestamp}.${payload}`;
24
+ const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');
25
+ const received = sigHeader.replace('sha256=', '');
26
+ // Constant-time comparison to prevent timing attacks
27
+ return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(received, 'hex'));
28
+ }
29
+
30
+ module.exports = { verifyWebhookSignature, TIMESTAMP_TOLERANCE_SECONDS };
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Timestamp utilities for AIACTA webhook events.
3
+ *
4
+ * §3.2 specifies "minute precision only" for event timestamps to prevent
5
+ * timing attacks that could re-identify users.
6
+ *
7
+ * Implementation note: the X-AI-Webhook-Timestamp header used for HMAC
8
+ * signing (§3.4) remains at UNIX second precision — this is the
9
+ * cryptographic nonce that prevents replay attacks. The event.timestamp
10
+ * field inside the JSON payload is truncated to minute precision.
11
+ */
12
+ 'use strict';
13
+
14
+ /**
15
+ * Returns the current time truncated to minute precision, as ISO 8601.
16
+ * e.g. "2026-03-24T09:14:00Z" (seconds and sub-seconds always zero)
17
+ */
18
+ function nowMinutePrecision() {
19
+ const d = new Date();
20
+ d.setSeconds(0, 0);
21
+ return d.toISOString().replace('.000Z', ':00Z').replace(/\.\d{3}Z$/, ':00Z');
22
+ }
23
+
24
+ /**
25
+ * Truncates any ISO 8601 string to minute precision.
26
+ * @param {string} isoString
27
+ * @returns {string}
28
+ */
29
+ function truncateToMinute(isoString) {
30
+ const d = new Date(isoString);
31
+ d.setSeconds(0, 0);
32
+ return d.toISOString().slice(0, 16) + ':00Z';
33
+ }
34
+
35
+ module.exports = { nowMinutePrecision, truncateToMinute };
@@ -0,0 +1,5 @@
1
+ """AIACTA Citation SDK for Python."""
2
+ from .signature import verify_webhook_signature
3
+ from .processor import process_event
4
+ from .retry import with_retry, RETRY_DELAYS_SECONDS
5
+ __all__ = ['verify_webhook_signature', 'process_event', 'with_retry', 'RETRY_DELAYS_SECONDS']
@@ -0,0 +1,8 @@
1
+ """Processes CitationEvent or CitationBatch with idempotency (§3.2)."""
2
+ async def process_event(event: dict, *, is_processed, mark_processed, on_event):
3
+ events = event.get("events", [event])
4
+ for e in events:
5
+ if await is_processed(e["idempotency_key"]):
6
+ continue
7
+ await on_event(e)
8
+ await mark_processed(e["idempotency_key"])
@@ -0,0 +1,11 @@
1
+ """Retry schedule matching §3.5."""
2
+ import asyncio
3
+ RETRY_DELAYS_SECONDS = [0, 30, 300, 1800, 7200, 43200]
4
+ async def with_retry(fn):
5
+ for attempt, delay in enumerate(RETRY_DELAYS_SECONDS):
6
+ if delay: await asyncio.sleep(delay)
7
+ try:
8
+ return await fn()
9
+ except Exception as e:
10
+ if attempt == len(RETRY_DELAYS_SECONDS) - 1:
11
+ raise RuntimeError(f"Dead-lettered after {len(RETRY_DELAYS_SECONDS)} attempts: {e}") from e
@@ -0,0 +1,13 @@
1
+ """Cryptographic webhook signature verification (§3.4)."""
2
+ import hashlib, hmac, time
3
+
4
+ TIMESTAMP_TOLERANCE_SECONDS = 300
5
+
6
+ def verify_webhook_signature(payload: bytes, timestamp: str, sig_header: str, secret: str) -> bool:
7
+ now = int(time.time())
8
+ if abs(now - int(timestamp)) > TIMESTAMP_TOLERANCE_SECONDS:
9
+ raise ValueError("Timestamp outside tolerance window — possible replay attack")
10
+ signed = f"{timestamp}.".encode() + payload
11
+ expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
12
+ received = sig_header.removeprefix("sha256=")
13
+ return hmac.compare_digest(expected, received)
@@ -0,0 +1,4 @@
1
+ cryptography>=41.0
2
+ requests>=2.31
3
+ pytest>=7.0
4
+ pytest-cov>=4.0
@@ -0,0 +1,9 @@
1
+ from setuptools import setup, find_packages
2
+ setup(
3
+ name='ai-citation-sdk',
4
+ version='1.0.0',
5
+ description='AIACTA Citation Webhook SDK for Python (Proposal 2 §3.4)',
6
+ packages=find_packages(),
7
+ python_requires='>=3.9',
8
+ install_requires=['cryptography>=41.0', 'requests>=2.31'],
9
+ )
@@ -0,0 +1,39 @@
1
+ const { processEvent } = require('../src/node/processor');
2
+
3
+ test('skips already-processed events', async () => {
4
+ const processed = new Set(['idem_duplicate']);
5
+ let called = 0;
6
+ await processEvent({ idempotency_key: 'idem_duplicate', event_type: 'citation.generated' }, {
7
+ isProcessed: k => processed.has(k),
8
+ markProcessed: k => processed.add(k),
9
+ onEvent: async () => { called++; },
10
+ });
11
+ expect(called).toBe(0);
12
+ });
13
+
14
+ test('processes new events and marks them', async () => {
15
+ const processed = new Set();
16
+ let called = 0;
17
+ await processEvent({ idempotency_key: 'idem_new', event_type: 'citation.generated' }, {
18
+ isProcessed: k => processed.has(k),
19
+ markProcessed: k => processed.add(k),
20
+ onEvent: async () => { called++; },
21
+ });
22
+ expect(called).toBe(1);
23
+ expect(processed.has('idem_new')).toBe(true);
24
+ });
25
+
26
+ test('processes batch events individually', async () => {
27
+ const processed = new Set();
28
+ const events = [
29
+ { idempotency_key: 'k1', event_type: 'citation.generated' },
30
+ { idempotency_key: 'k2', event_type: 'citation.generated' },
31
+ ];
32
+ let called = 0;
33
+ await processEvent({ events }, {
34
+ isProcessed: k => processed.has(k),
35
+ markProcessed: k => processed.add(k),
36
+ onEvent: async () => { called++; },
37
+ });
38
+ expect(called).toBe(2);
39
+ });
@@ -0,0 +1,13 @@
1
+ const { RETRY_DELAYS_SECONDS } = require('../src/node/retry');
2
+
3
+ test('retry schedule has 6 attempts', () => {
4
+ expect(RETRY_DELAYS_SECONDS).toHaveLength(6);
5
+ });
6
+
7
+ test('first attempt is immediate', () => {
8
+ expect(RETRY_DELAYS_SECONDS[0]).toBe(0);
9
+ });
10
+
11
+ test('last delay is 12 hours', () => {
12
+ expect(RETRY_DELAYS_SECONDS[5]).toBe(43200);
13
+ });
@@ -0,0 +1,26 @@
1
+ const crypto = require('crypto');
2
+ const { verifyWebhookSignature } = require('../src/node/signature');
3
+
4
+ function makeHeader(payload, timestamp, secret) {
5
+ const signed = `${timestamp}.${payload}`;
6
+ const hex = crypto.createHmac('sha256', secret).update(signed).digest('hex');
7
+ return `sha256=${hex}`;
8
+ }
9
+
10
+ test('valid signature passes', () => {
11
+ const ts = String(Math.floor(Date.now() / 1000));
12
+ const payload = '{"event_type":"citation.generated"}';
13
+ const sig = makeHeader(payload, ts, 'mysecret');
14
+ expect(verifyWebhookSignature(payload, ts, sig, 'mysecret')).toBe(true);
15
+ });
16
+
17
+ test('tampered payload fails', () => {
18
+ const ts = String(Math.floor(Date.now() / 1000));
19
+ const sig = makeHeader('original', ts, 'mysecret');
20
+ expect(verifyWebhookSignature('tampered', ts, sig, 'mysecret')).toBe(false);
21
+ });
22
+
23
+ test('old timestamp throws', () => {
24
+ const oldTs = String(Math.floor(Date.now() / 1000) - 400);
25
+ expect(() => verifyWebhookSignature('{}', oldTs, 'sha256=abc', 'secret')).toThrow();
26
+ });