@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.
- package/LICENSE +21 -0
- package/README.md +193 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +12 -0
- package/dist/middleware/index.d.ts +1 -0
- package/dist/middleware/index.js +2 -0
- package/dist/runtime/config.d.ts +46 -0
- package/dist/runtime/config.js +81 -0
- package/dist/runtime/middleware/ai-bot-detect.d.ts +2 -0
- package/dist/runtime/middleware/ai-bot-detect.js +73 -0
- package/dist/runtime/server/cache-store.d.ts +8 -0
- package/dist/runtime/server/cache-store.js +27 -0
- package/dist/runtime/server/routes/webhook.d.ts +2 -0
- package/dist/runtime/server/routes/webhook.js +90 -0
- package/dist/runtime/server/routes/well-known-aidp.d.ts +2 -0
- package/dist/runtime/server/routes/well-known-aidp.js +79 -0
- package/dist/runtime/server/routes/well-known-content.d.ts +2 -0
- package/dist/runtime/server/routes/well-known-content.js +84 -0
- package/dist/runtime/server/routes/well-known-directory.d.ts +2 -0
- package/dist/runtime/server/routes/well-known-directory.js +99 -0
- package/dist/runtime/server/utils/aidp-verify.d.ts +152 -0
- package/dist/runtime/server/utils/aidp-verify.js +332 -0
- package/dist/runtime/server/utils/bot-detect.d.ts +26 -0
- package/dist/runtime/server/utils/bot-detect.js +75 -0
- package/dist/runtime/server/utils/cache.d.ts +35 -0
- package/dist/runtime/server/utils/cache.js +80 -0
- package/dist/runtime/server/utils/content-registry.d.ts +3 -0
- package/dist/runtime/server/utils/content-registry.js +24 -0
- package/dist/runtime/server/utils/fetch-content.d.ts +14 -0
- package/dist/runtime/server/utils/fetch-content.js +53 -0
- package/dist/runtime/server/utils/fetch-directive.d.ts +21 -0
- package/dist/runtime/server/utils/fetch-directive.js +52 -0
- package/dist/runtime/server/utils/fetch-directory.d.ts +21 -0
- package/dist/runtime/server/utils/fetch-directory.js +59 -0
- package/dist/runtime/server/utils/hmac-verify.d.ts +37 -0
- package/dist/runtime/server/utils/hmac-verify.js +63 -0
- package/dist/runtime/server/utils/impression-queue.d.ts +33 -0
- package/dist/runtime/server/utils/impression-queue.js +145 -0
- package/dist/runtime/server/utils/query.d.ts +14 -0
- package/dist/runtime/server/utils/query.js +33 -0
- package/dist/runtime/version.d.ts +2 -0
- package/dist/runtime/version.js +2 -0
- package/package.json +62 -0
- package/src/components/AidpContent.astro +23 -0
- 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
|
package/dist/index.d.ts
ADDED
|
@@ -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,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,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,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,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
|
+
}
|