@speakspec/astro 0.0.1

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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +193 -0
  3. package/dist/index.d.ts +10 -0
  4. package/dist/index.js +12 -0
  5. package/dist/middleware/index.d.ts +1 -0
  6. package/dist/middleware/index.js +2 -0
  7. package/dist/runtime/config.d.ts +46 -0
  8. package/dist/runtime/config.js +81 -0
  9. package/dist/runtime/middleware/ai-bot-detect.d.ts +2 -0
  10. package/dist/runtime/middleware/ai-bot-detect.js +73 -0
  11. package/dist/runtime/server/cache-store.d.ts +8 -0
  12. package/dist/runtime/server/cache-store.js +27 -0
  13. package/dist/runtime/server/routes/webhook.d.ts +2 -0
  14. package/dist/runtime/server/routes/webhook.js +90 -0
  15. package/dist/runtime/server/routes/well-known-aidp.d.ts +2 -0
  16. package/dist/runtime/server/routes/well-known-aidp.js +79 -0
  17. package/dist/runtime/server/routes/well-known-content.d.ts +2 -0
  18. package/dist/runtime/server/routes/well-known-content.js +84 -0
  19. package/dist/runtime/server/routes/well-known-directory.d.ts +2 -0
  20. package/dist/runtime/server/routes/well-known-directory.js +99 -0
  21. package/dist/runtime/server/utils/aidp-verify.d.ts +152 -0
  22. package/dist/runtime/server/utils/aidp-verify.js +332 -0
  23. package/dist/runtime/server/utils/bot-detect.d.ts +26 -0
  24. package/dist/runtime/server/utils/bot-detect.js +75 -0
  25. package/dist/runtime/server/utils/cache.d.ts +35 -0
  26. package/dist/runtime/server/utils/cache.js +80 -0
  27. package/dist/runtime/server/utils/content-registry.d.ts +3 -0
  28. package/dist/runtime/server/utils/content-registry.js +24 -0
  29. package/dist/runtime/server/utils/fetch-content.d.ts +14 -0
  30. package/dist/runtime/server/utils/fetch-content.js +53 -0
  31. package/dist/runtime/server/utils/fetch-directive.d.ts +21 -0
  32. package/dist/runtime/server/utils/fetch-directive.js +52 -0
  33. package/dist/runtime/server/utils/fetch-directory.d.ts +21 -0
  34. package/dist/runtime/server/utils/fetch-directory.js +59 -0
  35. package/dist/runtime/server/utils/hmac-verify.d.ts +37 -0
  36. package/dist/runtime/server/utils/hmac-verify.js +63 -0
  37. package/dist/runtime/server/utils/impression-queue.d.ts +33 -0
  38. package/dist/runtime/server/utils/impression-queue.js +145 -0
  39. package/dist/runtime/server/utils/query.d.ts +14 -0
  40. package/dist/runtime/server/utils/query.js +33 -0
  41. package/dist/runtime/version.d.ts +2 -0
  42. package/dist/runtime/version.js +2 -0
  43. package/package.json +62 -0
  44. package/src/components/AidpContent.astro +23 -0
  45. package/src/components/AidpLinks.astro +10 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SpeakSpec
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,193 @@
1
+ # @speakspec/astro
2
+
3
+ > AIDP 0.3 publishing channel for Astro 5.
4
+
5
+ An Astro package that turns your site into a first-class AIDP source: publishes the entity directive at `/.well-known/aidp.json`, exposes signed content endpoints + a paginated content directory, injects `<link rel="aidp">` head tags, receives cache-invalidation webhooks, and observes AI-crawler traffic for upload to your dashboard.
6
+
7
+ Feature-equivalent to [`@speakspec/nuxt`](https://docs.speakspec.com/developer/sdk-nuxt) and [`@speakspec/next`](https://docs.speakspec.com/developer/sdk-next).
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pnpm add @speakspec/astro
13
+ ```
14
+
15
+ ## Configure (env vars)
16
+
17
+ ```env
18
+ # .env
19
+ SPEAKSPEC_ENTITY_ID=your-entity-slug
20
+ SPEAKSPEC_API_KEY=aidp_xxxxxxxxxxx
21
+ SPEAKSPEC_WEBHOOK_SECRET=...
22
+ PUBLIC_SPEAKSPEC_SITE_ORIGIN=https://yoursite.com
23
+ SPEAKSPEC_BOT_TRACKING=true
24
+ SPEAKSPEC_BOT_UPLOAD=true
25
+ ```
26
+
27
+ ## Wire the well-known routes
28
+
29
+ Astro requires `output: 'server'` (or `output: 'hybrid'`) to serve dynamic API routes. Add one route file per AIDP endpoint:
30
+
31
+ ```ts
32
+ // src/pages/.well-known/aidp.json.ts
33
+ import { aidpEntityRoute } from '@speakspec/astro'
34
+ export const GET = aidpEntityRoute()
35
+ ```
36
+
37
+ ```ts
38
+ // src/pages/.well-known/aidp/content/[id].json.ts
39
+ import { aidpContentRoute } from '@speakspec/astro'
40
+ export const GET = aidpContentRoute()
41
+ ```
42
+
43
+ ```ts
44
+ // src/pages/.well-known/aidp/content/index.ts
45
+ import { aidpDirectoryRoute } from '@speakspec/astro'
46
+ export const GET = aidpDirectoryRoute()
47
+ ```
48
+
49
+ ```ts
50
+ // src/pages/api/aidp/invalidate.ts ← NO leading underscore
51
+ import { aidpWebhookRoute } from '@speakspec/astro'
52
+ export const POST = aidpWebhookRoute()
53
+ ```
54
+
55
+ > Astro 5 excludes any path segment starting with `_` from routing
56
+ > (treats it as private). Use `api/aidp/...` (no leading underscore).
57
+ > The path you register with the SpeakSpec dashboard must match.
58
+
59
+ ## Wire the bot-detection middleware
60
+
61
+ ```ts
62
+ // src/middleware.ts
63
+ import { aidpBotMiddleware } from '@speakspec/astro/middleware'
64
+ export const onRequest = aidpBotMiddleware()
65
+ ```
66
+
67
+ If you already have middleware, sequence them:
68
+
69
+ ```ts
70
+ import { sequence } from 'astro:middleware'
71
+ import { aidpBotMiddleware } from '@speakspec/astro/middleware'
72
+
73
+ export const onRequest = sequence(myExisting, aidpBotMiddleware())
74
+ ```
75
+
76
+ ## Inject HTML link tags
77
+
78
+ ```astro
79
+ ---
80
+ // src/layouts/BaseLayout.astro
81
+ import AidpLinks from '@speakspec/astro/components/AidpLinks.astro'
82
+ ---
83
+ <html>
84
+ <head>
85
+ <AidpLinks />
86
+ </head>
87
+ <body><slot /></body>
88
+ </html>
89
+ ```
90
+
91
+ For per-page binding on article / product / policy pages:
92
+
93
+ ```astro
94
+ ---
95
+ // src/pages/articles/[id].astro
96
+ import AidpContent from '@speakspec/astro/components/AidpContent.astro'
97
+ const article = await loadArticle(Astro.params.id)
98
+ ---
99
+ <AidpContent contentId={article.id} pathname={`/articles/${article.id}`} />
100
+ <article set:html={article.body} />
101
+ ```
102
+
103
+ `<AidpContent />` registers the `(path → content_id)` mapping with the SDK so subsequent AI crawler hits get enriched with `content_id`.
104
+
105
+ ## Cache layer
106
+
107
+ Default in-memory cache. Plug in Redis / fs / etc. at boot:
108
+
109
+ ```ts
110
+ // src/server-init.ts (called from astro:server:setup integration)
111
+ import { setCacheStore } from '@speakspec/astro'
112
+ import { redisStore } from './my-cache'
113
+
114
+ setCacheStore(redisStore)
115
+ ```
116
+
117
+ Any object satisfying:
118
+
119
+ ```ts
120
+ interface FullStore {
121
+ getItem<T>(key: string): Promise<T | null>
122
+ setItem(key: string, value: unknown): Promise<void>
123
+ removeItem(key: string): Promise<void>
124
+ getKeys(base: string): Promise<string[]>
125
+ }
126
+ ```
127
+
128
+ works.
129
+
130
+ ## Cache tuning
131
+
132
+ The SDK serves three well-known routes with `Cache-Control` headers
133
+ tuned for fast revocation propagation. If you have Cloudflare /
134
+ CloudFront in front of your site, those headers are what the CDN
135
+ respects — so they directly bound how long it takes a revoked fact
136
+ to disappear from AI agent answers.
137
+
138
+ There are two TTLs to think about:
139
+
140
+ | Layer | What it does | Default | Affects |
141
+ |---|---|---|---|
142
+ | **SDK internal** | how long the SDK process reuses a fetched bundle before re-fetching from SpeakSpec | 300s | origin load on SpeakSpec |
143
+ | **`Cache-Control: max-age`** | how long downstream caches (CDN + AI agents) reuse the response | 60s (entity/directory), 300s (content) | revocation propagation, CDN cost |
144
+
145
+ **Why entity = 60s but content = 300s by default?** The entity directive (`/.well-known/aidp.json`) is the revocation pivot — when a customer revokes a fact, this is the document AI agents re-fetch first to learn what's still valid. Short `max-age` keeps revocation fast. Per-content envelopes (`/.well-known/aidp/content/[id].json`) are content-addressed: each `updated_at` produces a new signed bundle, so longer caching is safe.
146
+
147
+ **Setting `max-age=0`** disables CDN caching for that route but does NOT disable `stale-while-revalidate` — the CDN still serves stale within the SWR window while it revalidates. To fully disable caching, set both `*_MAX_AGE=0` and `*_SWR=0`.
148
+
149
+ The SDK internal TTL is mostly the safety net for missed webhooks —
150
+ when an entity is revoked, SpeakSpec sends a webhook that clears the
151
+ SDK cache instantly. Downstream `max-age` is the real ceiling on how
152
+ quickly AI agents see the revocation.
153
+
154
+ All values are configurable via env vars (seconds):
155
+
156
+ ```env
157
+ # SDK internal cache (default 300)
158
+ SPEAKSPEC_CACHE_TTL_SEC=300
159
+
160
+ # /.well-known/aidp.json (default 60 / 300)
161
+ SPEAKSPEC_ENTITY_MAX_AGE=60
162
+ SPEAKSPEC_ENTITY_SWR=300
163
+
164
+ # /.well-known/aidp/content/[id] (default 300 / 600)
165
+ SPEAKSPEC_CONTENT_MAX_AGE=300
166
+ SPEAKSPEC_CONTENT_SWR=600
167
+
168
+ # /.well-known/aidp/content (default 60 / 300)
169
+ SPEAKSPEC_DIRECTORY_MAX_AGE=60
170
+ SPEAKSPEC_DIRECTORY_SWR=300
171
+ ```
172
+
173
+ **Trade-off**: longer `max-age` means lower origin/CDN bill but
174
+ slower revocation. Worst-case revocation propagation is bounded by
175
+ `max-age + stale-while-revalidate`. If you want sub-minute revocation
176
+ across Cloudflare, also wire SpeakSpec's webhook to a Cloudflare
177
+ purge — out of SDK scope.
178
+
179
+ ## Caveats vs `@speakspec/nuxt`
180
+
181
+ - **Output mode**: requires Astro `output: 'server'` or `'hybrid'` for API routes to be dynamic. `output: 'static'` (the default) bakes all routes at build time and won't update directives without a rebuild.
182
+ - **Multi-instance**: in-memory cache + impression queue are per-process. Customers running on Cloudflare or similar edge platforms should provide a Redis-backed cache via `setCacheStore`.
183
+ - **First-hit content_id**: `<AidpContent />` registers on render, so the very first AI crawler hit on a path lands with `content_id=null`. Subsequent hits are enriched.
184
+
185
+ ## Spec & references
186
+
187
+ - [AIDP 0.3 §4.8 Cryptographic Proof](https://docs.speakspec.com/spec/transport#cryptographic-proof)
188
+ - [AIDP 0.3 §8.5–8.13 Transport](https://docs.speakspec.com/spec/transport)
189
+ - [Authenticated API](https://docs.speakspec.com/api/authenticated)
190
+
191
+ ## License
192
+
193
+ MIT
@@ -0,0 +1,10 @@
1
+ export { aidpEntityRoute } from './runtime/server/routes/well-known-aidp';
2
+ export { aidpContentRoute } from './runtime/server/routes/well-known-content';
3
+ export { aidpDirectoryRoute } from './runtime/server/routes/well-known-directory';
4
+ export { aidpWebhookRoute } from './runtime/server/routes/webhook';
5
+ export { setCacheStore, getCacheStore, type FullStore } from './runtime/server/cache-store';
6
+ export { type CacheStorage } from './runtime/server/utils/cache';
7
+ export { readConfig, validateEntityId, type SpeakspecConfig } from './runtime/config';
8
+ export { verifyBundle, fetchJwks, fetchRevocationList, type VerifyResult, type VerifyFailReason, } from './runtime/server/utils/aidp-verify';
9
+ export { detectAICrawler, isAICrawler, type CrawlerMatch, type CrawlerSource } from './runtime/server/utils/bot-detect';
10
+ export { registerContent, lookupContentId } from './runtime/server/utils/content-registry';
package/dist/index.js ADDED
@@ -0,0 +1,12 @@
1
+ // @speakspec/astro — main entry. Exports the route-handler factories
2
+ // and top-level config / cache primitives.
3
+ export { aidpEntityRoute } from './runtime/server/routes/well-known-aidp';
4
+ export { aidpContentRoute } from './runtime/server/routes/well-known-content';
5
+ export { aidpDirectoryRoute } from './runtime/server/routes/well-known-directory';
6
+ export { aidpWebhookRoute } from './runtime/server/routes/webhook';
7
+ export { setCacheStore, getCacheStore } from './runtime/server/cache-store';
8
+ export { readConfig, validateEntityId } from './runtime/config';
9
+ // Re-export framework-agnostic primitives.
10
+ export { verifyBundle, fetchJwks, fetchRevocationList, } from './runtime/server/utils/aidp-verify';
11
+ export { detectAICrawler, isAICrawler } from './runtime/server/utils/bot-detect';
12
+ export { registerContent, lookupContentId } from './runtime/server/utils/content-registry';
@@ -0,0 +1 @@
1
+ export { aidpBotMiddleware } from '../runtime/middleware/ai-bot-detect';
@@ -0,0 +1,2 @@
1
+ // `@speakspec/astro/middleware` sub-entry — middleware-only export.
2
+ export { aidpBotMiddleware } from '../runtime/middleware/ai-bot-detect';
@@ -0,0 +1,46 @@
1
+ export interface SpeakspecCacheConfig {
2
+ /** SDK-internal cache TTL (seconds) — how long the SDK process
3
+ * reuses a fetched bundle before re-fetching from SpeakSpec. The
4
+ * webhook receiver invalidates this cache on directive change, so
5
+ * this TTL is mostly the safety net for missed webhooks. */
6
+ ttlSec: number;
7
+ /** /.well-known/aidp.json `Cache-Control: max-age` (seconds). This
8
+ * is the floor for revocation propagation through downstream CDN
9
+ * caches (Cloudflare, CloudFront, etc.). Lower = faster revocation
10
+ * + more origin load; higher = the opposite. */
11
+ entityMaxAge: number;
12
+ /** /.well-known/aidp.json `Cache-Control: stale-while-revalidate`. */
13
+ entitySwr: number;
14
+ /** /.well-known/aidp/content/[id] `Cache-Control: max-age`. */
15
+ contentMaxAge: number;
16
+ /** /.well-known/aidp/content/[id] `Cache-Control: stale-while-revalidate`. */
17
+ contentSwr: number;
18
+ /** /.well-known/aidp/content `Cache-Control: max-age`. */
19
+ directoryMaxAge: number;
20
+ /** /.well-known/aidp/content `Cache-Control: stale-while-revalidate`. */
21
+ directorySwr: number;
22
+ }
23
+ export interface SpeakspecConfig {
24
+ entityId: string;
25
+ apiKey: string;
26
+ webhookSecret: string;
27
+ endpoint: string;
28
+ siteOrigin: string;
29
+ cache: SpeakspecCacheConfig;
30
+ botTracking: {
31
+ enabled: boolean;
32
+ excludePaths: string[];
33
+ upload: {
34
+ enabled: boolean;
35
+ batchSize: number;
36
+ flushIntervalMs: number;
37
+ maxQueueBytes: number;
38
+ onError: 'fallback-stdout' | 'silent';
39
+ };
40
+ };
41
+ }
42
+ export declare const DEFAULT_CACHE_CONFIG: SpeakspecCacheConfig;
43
+ export declare function readConfig(): SpeakspecConfig;
44
+ export declare function validateEntityId(entityId: string): void;
45
+ /** Build a `Cache-Control` header value from max-age + swr seconds. */
46
+ export declare function buildCacheControl(maxAge: number, swr: number): string;
@@ -0,0 +1,81 @@
1
+ // Runtime configuration for @speakspec/astro.
2
+ //
3
+ // Astro reads env vars from import.meta.env (build-time) for client
4
+ // code, and process.env (runtime) for server code. Since AIDP route
5
+ // handlers are server-only, we read process.env directly.
6
+ const DEFAULT_EXCLUDE_PATHS = ['/_astro/', '/api/aidp/'];
7
+ export const DEFAULT_CACHE_CONFIG = {
8
+ ttlSec: 300,
9
+ entityMaxAge: 60,
10
+ entitySwr: 300,
11
+ contentMaxAge: 300,
12
+ contentSwr: 600,
13
+ directoryMaxAge: 60,
14
+ directorySwr: 300,
15
+ };
16
+ /** Strict integer-seconds parser. Rejects anything that isn't a plain
17
+ * decimal digit string ("60"), so quirky `Number()` coercions like
18
+ * `0x10`, `1e3`, `+60`, or numbers past `Number.MAX_SAFE_INTEGER`
19
+ * fall back instead of silently producing surprising Cache-Control
20
+ * values. Empty / unset env vars are treated as "use the default". */
21
+ function readPositiveInt(value, fallback, label) {
22
+ if (value == null)
23
+ return fallback;
24
+ const trimmed = value.trim();
25
+ if (trimmed === '')
26
+ return fallback;
27
+ if (!/^\d+$/.test(trimmed)) {
28
+ console.warn(`[@speakspec/astro] invalid ${label}=${value}, falling back to ${fallback}`);
29
+ return fallback;
30
+ }
31
+ const n = Number(trimmed);
32
+ if (!Number.isSafeInteger(n)) {
33
+ console.warn(`[@speakspec/astro] ${label}=${value} exceeds safe integer range, falling back to ${fallback}`);
34
+ return fallback;
35
+ }
36
+ return n;
37
+ }
38
+ export function readConfig() {
39
+ const env = process.env;
40
+ return {
41
+ entityId: env.SPEAKSPEC_ENTITY_ID ?? '',
42
+ apiKey: env.SPEAKSPEC_API_KEY ?? '',
43
+ webhookSecret: env.SPEAKSPEC_WEBHOOK_SECRET ?? '',
44
+ endpoint: env.SPEAKSPEC_ENDPOINT ?? 'https://api.speakspec.com',
45
+ siteOrigin: env.PUBLIC_SPEAKSPEC_SITE_ORIGIN ?? env.PUBLIC_SITE_URL ?? '',
46
+ cache: {
47
+ ttlSec: readPositiveInt(env.SPEAKSPEC_CACHE_TTL_SEC, DEFAULT_CACHE_CONFIG.ttlSec, 'SPEAKSPEC_CACHE_TTL_SEC'),
48
+ entityMaxAge: readPositiveInt(env.SPEAKSPEC_ENTITY_MAX_AGE, DEFAULT_CACHE_CONFIG.entityMaxAge, 'SPEAKSPEC_ENTITY_MAX_AGE'),
49
+ entitySwr: readPositiveInt(env.SPEAKSPEC_ENTITY_SWR, DEFAULT_CACHE_CONFIG.entitySwr, 'SPEAKSPEC_ENTITY_SWR'),
50
+ contentMaxAge: readPositiveInt(env.SPEAKSPEC_CONTENT_MAX_AGE, DEFAULT_CACHE_CONFIG.contentMaxAge, 'SPEAKSPEC_CONTENT_MAX_AGE'),
51
+ contentSwr: readPositiveInt(env.SPEAKSPEC_CONTENT_SWR, DEFAULT_CACHE_CONFIG.contentSwr, 'SPEAKSPEC_CONTENT_SWR'),
52
+ directoryMaxAge: readPositiveInt(env.SPEAKSPEC_DIRECTORY_MAX_AGE, DEFAULT_CACHE_CONFIG.directoryMaxAge, 'SPEAKSPEC_DIRECTORY_MAX_AGE'),
53
+ directorySwr: readPositiveInt(env.SPEAKSPEC_DIRECTORY_SWR, DEFAULT_CACHE_CONFIG.directorySwr, 'SPEAKSPEC_DIRECTORY_SWR'),
54
+ },
55
+ botTracking: {
56
+ enabled: env.SPEAKSPEC_BOT_TRACKING === 'true',
57
+ excludePaths: env.SPEAKSPEC_BOT_EXCLUDE_PATHS
58
+ ? env.SPEAKSPEC_BOT_EXCLUDE_PATHS.split(',').map(s => s.trim()).filter(Boolean)
59
+ : DEFAULT_EXCLUDE_PATHS,
60
+ upload: {
61
+ enabled: env.SPEAKSPEC_BOT_UPLOAD === 'true',
62
+ batchSize: Number(env.SPEAKSPEC_BOT_BATCH_SIZE ?? 50),
63
+ flushIntervalMs: Number(env.SPEAKSPEC_BOT_FLUSH_MS ?? 60_000),
64
+ maxQueueBytes: Number(env.SPEAKSPEC_BOT_QUEUE_BYTES ?? 2 * 1024 * 1024),
65
+ onError: (env.SPEAKSPEC_BOT_ON_ERROR === 'silent' ? 'silent' : 'fallback-stdout'),
66
+ },
67
+ },
68
+ };
69
+ }
70
+ export function validateEntityId(entityId) {
71
+ if (entityId && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(entityId)) {
72
+ console.warn(`[@speakspec/astro] entityId %o does not match SpeakSpec's slug rule `
73
+ + `(lowercase alphanumerics and hyphens, no leading/trailing hyphen). `
74
+ + `Verify against your SpeakSpec dashboard — pasting the URN form `
75
+ + `(urn:aidp:entity:foo) instead of the bare slug is a common mistake.`, entityId);
76
+ }
77
+ }
78
+ /** Build a `Cache-Control` header value from max-age + swr seconds. */
79
+ export function buildCacheControl(maxAge, swr) {
80
+ return `public, max-age=${maxAge}, stale-while-revalidate=${swr}`;
81
+ }
@@ -0,0 +1,2 @@
1
+ import type { MiddlewareHandler } from 'astro';
2
+ export declare function aidpBotMiddleware(): MiddlewareHandler;
@@ -0,0 +1,73 @@
1
+ // Astro middleware factory for AI crawler detection.
2
+ //
3
+ // Usage:
4
+ // // src/middleware.ts
5
+ // import { aidpBotMiddleware } from '@speakspec/astro/middleware'
6
+ // export const onRequest = aidpBotMiddleware()
7
+ //
8
+ // Behavior mirrors the Nuxt SDK:
9
+ // - Off when SPEAKSPEC_BOT_TRACKING !== 'true'
10
+ // - Skips paths under SPEAKSPEC_BOT_EXCLUDE_PATHS
11
+ // - On AI crawler match: emits structured impression
12
+ // - upload.enabled → batched POST to SpeakSpec
13
+ // - otherwise → console.log fallback
14
+ // - Never blocks the request — pass-through observer
15
+ import { detectAICrawler, isExcludedPath } from '../server/utils/bot-detect';
16
+ import { lookupContentId } from '../server/utils/content-registry';
17
+ import { configureQueue, enqueueImpression } from '../server/utils/impression-queue';
18
+ import { readConfig } from '../config';
19
+ let queueConfigured = false;
20
+ export function aidpBotMiddleware() {
21
+ return async (context, next) => {
22
+ const config = readConfig();
23
+ if (!config.botTracking.enabled) {
24
+ return next();
25
+ }
26
+ const path = context.url.pathname;
27
+ if (isExcludedPath(path, config.botTracking.excludePaths)) {
28
+ return next();
29
+ }
30
+ const ua = context.request.headers.get('user-agent') ?? '';
31
+ const matched = detectAICrawler(ua);
32
+ if (!matched) {
33
+ return next();
34
+ }
35
+ const impression = {
36
+ msg: 'aidp.crawler_impression',
37
+ crawler: matched.label,
38
+ crawler_source: matched.source,
39
+ path,
40
+ user_agent: ua.slice(0, 256),
41
+ ts: new Date().toISOString(),
42
+ };
43
+ if (config.entityId)
44
+ impression.entity_id = config.entityId;
45
+ const cid = lookupContentId(path);
46
+ if (cid)
47
+ impression.content_id = cid;
48
+ const ip = context.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
49
+ ?? context.request.headers.get('x-real-ip')
50
+ ?? undefined;
51
+ if (ip)
52
+ impression.client_ip = ip;
53
+ const upload = config.botTracking.upload;
54
+ if (upload.enabled && config.entityId && config.apiKey) {
55
+ if (!queueConfigured) {
56
+ configureQueue({
57
+ endpoint: config.endpoint,
58
+ apiKey: config.apiKey,
59
+ batchSize: upload.batchSize,
60
+ flushIntervalMs: upload.flushIntervalMs,
61
+ maxQueueBytes: upload.maxQueueBytes,
62
+ onError: upload.onError,
63
+ });
64
+ queueConfigured = true;
65
+ }
66
+ enqueueImpression(impression);
67
+ }
68
+ else {
69
+ console.log(JSON.stringify(impression));
70
+ }
71
+ return next();
72
+ };
73
+ }
@@ -0,0 +1,8 @@
1
+ import type { CacheStorage } from './utils/cache';
2
+ export interface FullStore extends CacheStorage {
3
+ setItem: (key: string, value: unknown) => Promise<void>;
4
+ getItem: <T = unknown>(key: string) => Promise<T | null>;
5
+ }
6
+ export declare function getCacheStore(): FullStore;
7
+ export declare function setCacheStore(store: FullStore): void;
8
+ export declare function resetCacheStoreForTesting(): void;
@@ -0,0 +1,27 @@
1
+ // In-memory cache store for SDK runtime. Per-process; customers can
2
+ // plug in Redis / fs / etc. via setCacheStore().
3
+ class InMemoryStore {
4
+ map = new Map();
5
+ async getItem(key) {
6
+ return this.map.get(key) ?? null;
7
+ }
8
+ async setItem(key, value) {
9
+ this.map.set(key, value);
10
+ }
11
+ async removeItem(key) {
12
+ this.map.delete(key);
13
+ }
14
+ async getKeys(base) {
15
+ return [...this.map.keys()].filter(k => k.startsWith(base));
16
+ }
17
+ }
18
+ let activeStore = new InMemoryStore();
19
+ export function getCacheStore() {
20
+ return activeStore;
21
+ }
22
+ export function setCacheStore(store) {
23
+ activeStore = store;
24
+ }
25
+ export function resetCacheStoreForTesting() {
26
+ activeStore = new InMemoryStore();
27
+ }
@@ -0,0 +1,2 @@
1
+ import type { APIRoute } from 'astro';
2
+ export declare function aidpWebhookRoute(): APIRoute;
@@ -0,0 +1,90 @@
1
+ // Astro API route factory for POST /api/_aidp/invalidate
2
+ //
3
+ // Usage:
4
+ // // src/pages/api/_aidp/invalidate.ts
5
+ // import { aidpWebhookRoute } from '@speakspec/astro'
6
+ // export const POST = aidpWebhookRoute()
7
+ import { Buffer } from 'node:buffer';
8
+ import { verifyHmacSignature, isTimestampFresh, urnToSlug } from '../utils/hmac-verify';
9
+ import { invalidateEntityCache, invalidateContentCache, } from '../utils/cache';
10
+ import { getCacheStore } from '../cache-store';
11
+ import { readConfig } from '../../config';
12
+ const MAX_WEBHOOK_BODY_BYTES = 64 * 1024;
13
+ const VALID_SCOPES = new Set(['entity', 'content']);
14
+ const VALID_EVENTS = new Set(['directive.updated']);
15
+ export function aidpWebhookRoute() {
16
+ return async ({ request }) => {
17
+ const config = readConfig();
18
+ if (!config.webhookSecret) {
19
+ return errorResponse(503, 'AIDP webhook receiver not configured: missing webhookSecret');
20
+ }
21
+ const signature = request.headers.get('x-aidp-signature');
22
+ const timestamp = request.headers.get('x-aidp-timestamp');
23
+ if (!signature || !timestamp) {
24
+ return errorResponse(400, 'missing X-AIDP-Signature or X-AIDP-Timestamp header');
25
+ }
26
+ if (!isTimestampFresh(timestamp)) {
27
+ return errorResponse(401, 'X-AIDP-Timestamp outside ±5 minute window (replay protection)');
28
+ }
29
+ const rawBuffer = Buffer.from(await request.arrayBuffer());
30
+ if (rawBuffer.byteLength === 0) {
31
+ return errorResponse(400, 'empty request body');
32
+ }
33
+ if (rawBuffer.byteLength > MAX_WEBHOOK_BODY_BYTES) {
34
+ return errorResponse(413, `webhook body exceeds ${MAX_WEBHOOK_BODY_BYTES} bytes`);
35
+ }
36
+ const bodyString = rawBuffer.toString('utf8');
37
+ const valid = verifyHmacSignature({
38
+ secret: config.webhookSecret,
39
+ timestamp,
40
+ body: bodyString,
41
+ signature,
42
+ });
43
+ if (!valid) {
44
+ return errorResponse(401, 'X-AIDP-Signature does not match');
45
+ }
46
+ let payload;
47
+ try {
48
+ payload = JSON.parse(bodyString);
49
+ }
50
+ catch {
51
+ return errorResponse(400, 'invalid JSON body');
52
+ }
53
+ if (!payload.scope || !payload.entity_id) {
54
+ return errorResponse(400, 'payload missing required fields (scope, entity_id)');
55
+ }
56
+ if (payload.event && !VALID_EVENTS.has(payload.event)) {
57
+ return errorResponse(400, `unsupported event "${payload.event}" (expected one of: ${[...VALID_EVENTS].join(', ')})`);
58
+ }
59
+ if (!VALID_SCOPES.has(payload.scope)) {
60
+ return errorResponse(400, `unsupported scope "${payload.scope}" (expected entity|content)`);
61
+ }
62
+ if (payload.scope === 'content' && !payload.content_id) {
63
+ return errorResponse(400, 'scope=content requires content_id');
64
+ }
65
+ if (payload.timestamp && payload.timestamp !== timestamp) {
66
+ return errorResponse(400, 'body.timestamp does not match X-AIDP-Timestamp header');
67
+ }
68
+ let slug;
69
+ try {
70
+ slug = urnToSlug(payload.entity_id);
71
+ }
72
+ catch (err) {
73
+ return errorResponse(400, err.message);
74
+ }
75
+ const store = getCacheStore();
76
+ if (payload.scope === 'entity') {
77
+ await invalidateEntityCache(store, slug);
78
+ }
79
+ else {
80
+ await invalidateContentCache(store, slug, payload.content_id);
81
+ }
82
+ return new Response(null, { status: 204 });
83
+ };
84
+ }
85
+ function errorResponse(status, message) {
86
+ return new Response(JSON.stringify({ error: { statusCode: status, statusMessage: message } }), {
87
+ status,
88
+ headers: { 'content-type': 'application/json; charset=utf-8' },
89
+ });
90
+ }
@@ -0,0 +1,2 @@
1
+ import type { APIRoute } from 'astro';
2
+ export declare function aidpEntityRoute(): APIRoute;
@@ -0,0 +1,79 @@
1
+ // Astro API route factory for /.well-known/aidp.json
2
+ //
3
+ // Usage:
4
+ // // src/pages/.well-known/aidp.json.ts
5
+ // import { aidpEntityRoute } from '@speakspec/astro'
6
+ // export const GET = aidpEntityRoute()
7
+ //
8
+ // Behavior (per AIDP transport spec §8.5–8.13):
9
+ // - Read cached payload + ETag; serve fresh + ETag + Cache-Control
10
+ // - Inbound If-None-Match → 304 short-circuit
11
+ // - Stale → fetch upstream with cached ETag; 304 → refresh; 200 → store
12
+ // - Upstream 4xx → 502 with detail; 5xx/network → serve stale or 502
13
+ import { fetchEntityDirective } from '../utils/fetch-directive';
14
+ import { cacheKey, isFresh, isUpstream4xx, respondWithCache, } from '../utils/cache';
15
+ import { getCacheStore } from '../cache-store';
16
+ import { readConfig, buildCacheControl } from '../../config';
17
+ export function aidpEntityRoute() {
18
+ return async ({ request }) => {
19
+ const config = readConfig();
20
+ if (!config.entityId) {
21
+ return errorResponse(503, 'AIDP module not configured: missing entityId');
22
+ }
23
+ const FRESH_CACHE_CONTROL = buildCacheControl(config.cache.entityMaxAge, config.cache.entitySwr);
24
+ const STALE_CACHE_CONTROL = buildCacheControl(10, 60);
25
+ const ttlMs = config.cache.ttlSec * 1000;
26
+ const inboundIfNoneMatch = request.headers.get('if-none-match') ?? undefined;
27
+ const store = getCacheStore();
28
+ const key = cacheKey('entity', config.entityId);
29
+ const cached = await store.getItem(key);
30
+ if (isFresh(cached)) {
31
+ return respondWithCache(cached.etag, cached.payload, FRESH_CACHE_CONTROL, inboundIfNoneMatch);
32
+ }
33
+ const upstreamIfNoneMatch = cached?.etag || undefined;
34
+ let result;
35
+ try {
36
+ result = await fetchEntityDirective({
37
+ endpoint: config.endpoint,
38
+ entityId: config.entityId,
39
+ apiKey: config.apiKey || undefined,
40
+ ifNoneMatch: upstreamIfNoneMatch,
41
+ });
42
+ }
43
+ catch (err) {
44
+ if (isUpstream4xx(err)) {
45
+ const status = err.response?.status;
46
+ return errorResponse(502, `AIDP upstream rejected the directive fetch (${status})`);
47
+ }
48
+ if (cached) {
49
+ return respondWithCache(cached.etag, cached.payload, STALE_CACHE_CONTROL, inboundIfNoneMatch);
50
+ }
51
+ return errorResponse(502, 'AIDP upstream unreachable and no cached payload available');
52
+ }
53
+ if (result.notModified && cached) {
54
+ const refreshed = {
55
+ payload: cached.payload,
56
+ etag: cached.etag,
57
+ expiresAt: Date.now() + ttlMs,
58
+ };
59
+ await store.setItem(key, refreshed);
60
+ return respondWithCache(refreshed.etag, refreshed.payload, FRESH_CACHE_CONTROL, inboundIfNoneMatch);
61
+ }
62
+ if (!result.payload) {
63
+ return errorResponse(502, 'AIDP upstream returned empty payload');
64
+ }
65
+ const fresh = {
66
+ payload: result.payload,
67
+ etag: result.etag,
68
+ expiresAt: Date.now() + ttlMs,
69
+ };
70
+ await store.setItem(key, fresh);
71
+ return respondWithCache(fresh.etag, fresh.payload, FRESH_CACHE_CONTROL, inboundIfNoneMatch);
72
+ };
73
+ }
74
+ function errorResponse(status, message) {
75
+ return new Response(JSON.stringify({ error: { statusCode: status, statusMessage: message } }), {
76
+ status,
77
+ headers: { 'content-type': 'application/json; charset=utf-8' },
78
+ });
79
+ }