@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 +243 -0
- package/package.json +17 -0
- package/src/go/aiacta.go +134 -0
- package/src/go/aiacta_test.go +87 -0
- package/src/go/go.mod +5 -0
- package/src/node/index.js +10 -0
- package/src/node/middleware.js +34 -0
- package/src/node/processor.js +28 -0
- package/src/node/retry.js +29 -0
- package/src/node/signature.js +30 -0
- package/src/node/timestamp.js +35 -0
- package/src/python/aiacta/__init__.py +5 -0
- package/src/python/aiacta/processor.py +8 -0
- package/src/python/aiacta/retry.py +11 -0
- package/src/python/aiacta/signature.py +13 -0
- package/src/python/requirements.txt +4 -0
- package/src/python/setup.py +9 -0
- package/tests/processor.test.js +39 -0
- package/tests/retry.test.js +13 -0
- package/tests/signature.test.js +26 -0
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
|
+
[](https://www.npmjs.com/package/@aiacta-org/ai-citation-sdk)
|
|
6
|
+
[](https://pypi.org/project/ai-citation-sdk/)
|
|
7
|
+
[](../../LICENSE)
|
|
8
|
+
[](../../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
|
+
}
|
package/src/go/aiacta.go
ADDED
|
@@ -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,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,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
|
+
});
|