@ampless/plugin-webhook 0.2.0-alpha.5 → 0.2.0-alpha.50
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.ja.md +134 -0
- package/README.md +32 -1
- package/dist/index.d.ts +12 -7
- package/dist/index.js +31 -17
- package/package.json +18 -3
package/README.ja.md
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
> English: [README.md](./README.md)
|
|
2
|
+
>
|
|
3
|
+
|
|
4
|
+
# @ampless/plugin-webhook
|
|
5
|
+
|
|
6
|
+
ampless イベントを 1 つ以上の外部 URL に POST で通知します。
|
|
7
|
+
|
|
8
|
+
> **プレリリース / アルファ版。** v1.0 まではマイナーバージョンでも破壊的変更が入る可能性があります。
|
|
9
|
+
|
|
10
|
+
**trusted** Lambda で実行されます — 署名シークレットを admin UI から管理できるため、再デプロイなしでキーローテーションが可能です。
|
|
11
|
+
|
|
12
|
+
## インストール
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install @ampless/plugin-webhook@alpha
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## 設定
|
|
19
|
+
|
|
20
|
+
`cms.config.ts` に記述します:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import webhookPlugin from '@ampless/plugin-webhook'
|
|
24
|
+
|
|
25
|
+
export default defineConfig({
|
|
26
|
+
// ...
|
|
27
|
+
plugins: [
|
|
28
|
+
webhookPlugin({
|
|
29
|
+
endpoints: [
|
|
30
|
+
{
|
|
31
|
+
url: 'https://example.com/hooks/ampless',
|
|
32
|
+
secret: process.env.WEBHOOK_SECRET,
|
|
33
|
+
events: ['content.published', 'content.unpublished', 'content.deleted'],
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
url: 'https://discord.com/api/webhooks/.../...',
|
|
37
|
+
// secret なし — Discord は署名を検証しない
|
|
38
|
+
events: ['content.published'],
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
}),
|
|
42
|
+
],
|
|
43
|
+
})
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
| オプション | デフォルト | 備考 |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| `endpoints[].url` | 必須 | POST 先の HTTPS エンドポイント |
|
|
49
|
+
| `endpoints[].secret` | なし | 設定時、本文を HMAC-SHA-256 で署名して `X-Ampless-Signature` ヘッダーで送信 |
|
|
50
|
+
| `endpoints[].events` | すべての `content.*` | このエンドポイントを発火するイベント種別を絞り込む |
|
|
51
|
+
| `endpoints[].headers` | `{}` | 全リクエストにマージされる追加ヘッダー |
|
|
52
|
+
| `endpoints[].timeoutMs` | `5000` | リクエストごとのタイムアウト |
|
|
53
|
+
| `url`(トップレベル) | なし | 単一エンドポイントのショートカット。`endpoints: [{ url, secret }]` と同等 |
|
|
54
|
+
| `secret`(トップレベル) | なし | トップレベルの `url` と対で使用 |
|
|
55
|
+
|
|
56
|
+
## リクエスト形式
|
|
57
|
+
|
|
58
|
+
```http
|
|
59
|
+
POST /hooks/ampless HTTP/1.1
|
|
60
|
+
Host: example.com
|
|
61
|
+
Content-Type: application/json
|
|
62
|
+
X-Ampless-Event: content.published
|
|
63
|
+
X-Ampless-Signature: sha256=<hex>
|
|
64
|
+
|
|
65
|
+
{
|
|
66
|
+
"type": "content.published",
|
|
67
|
+
"payload": {
|
|
68
|
+
"siteId": "default",
|
|
69
|
+
"postId": "post-001",
|
|
70
|
+
"slug": "hello",
|
|
71
|
+
"title": "Hello",
|
|
72
|
+
"status": "published",
|
|
73
|
+
"publishedAt": "2026-04-30T00:00:00.000Z",
|
|
74
|
+
"tags": ["intro"]
|
|
75
|
+
},
|
|
76
|
+
"timestamp": "2026-04-30T00:00:01.000Z"
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## 署名の検証(Node.js)
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
84
|
+
|
|
85
|
+
function verify(rawBody: string, signatureHeader: string, secret: string): boolean {
|
|
86
|
+
const expected = 'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex')
|
|
87
|
+
const a = Buffer.from(expected)
|
|
88
|
+
const b = Buffer.from(signatureHeader)
|
|
89
|
+
if (a.length !== b.length) return false
|
|
90
|
+
return timingSafeEqual(a, b)
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
HMAC は必ず JSON パース前の**生のリクエストボディ**に対して計算し、定数時間比較を使用してください。
|
|
95
|
+
|
|
96
|
+
## 署名シークレット(admin 管理)
|
|
97
|
+
|
|
98
|
+
Phase 6a から、Webhook プラグインは admin 管理の署名シークレットをサポートします。再デプロイなしで HMAC キーをローテーションできます。
|
|
99
|
+
|
|
100
|
+
### admin UI でのシークレット設定
|
|
101
|
+
|
|
102
|
+
1. `/admin/plugins/webhook` → **Secret settings** を開く。
|
|
103
|
+
2. 署名シークレットを入力して **Save** をクリック。
|
|
104
|
+
3. 数秒以内に新しいキーが有効になります — `git push` も Amplify リビルドも不要。
|
|
105
|
+
|
|
106
|
+
### シークレットの適用ルール
|
|
107
|
+
|
|
108
|
+
- **admin シークレットが設定済み** → **すべての** エンドポイントに一律適用。キーのローテーションを 1 箇所で管理できるため、本番環境ではこの方式を推奨します。
|
|
109
|
+
- **admin シークレット未設定** → 各エンドポイントはコンストラクタのオプションで渡した per-endpoint `secret` にフォールバック([設定](#設定) 参照)。初期セットアップに使い、本番移行後は admin 管理に切り替えてください。
|
|
110
|
+
|
|
111
|
+
コンストラクタの per-endpoint `secret` はクロージャ内にのみ保持され、プラグインマニフェストや公開成果物には**絶対に含まれません** — 設計上サーバーサイドに留まります。
|
|
112
|
+
|
|
113
|
+
### シークレットのローテーション
|
|
114
|
+
|
|
115
|
+
1. 新しいシークレットを生成(例: `openssl rand -hex 32`)。
|
|
116
|
+
2. 受信側を旧・新シークレットの両方で一時的に検証できるように更新(デュアル検証)。
|
|
117
|
+
3. admin UI で **Replace** → 新シークレットを入力 → **Save**。
|
|
118
|
+
4. 飛行中の Webhook がすべて処理されたら(約 30 秒)、受信側から旧シークレットを削除。
|
|
119
|
+
|
|
120
|
+
### 受信側での署名検証
|
|
121
|
+
|
|
122
|
+
receiver 側の実装は [署名の検証(Node.js)](#署名の検証nodejs) を参照してください — admin UI 経由か コンストラクタ経由かに関わらず、検証コードは同一です。
|
|
123
|
+
|
|
124
|
+
## リトライ動作
|
|
125
|
+
|
|
126
|
+
いずれかのエンドポイントが 2xx 以外のレスポンスを返した場合(またはタイムアウトした場合)、プラグインは例外を投げます。トラストレベルプロセッサーの Lambda が SQS に再スローし、デッドレターキューに移動するまで最大 3 回リトライされます。同じイベントが複数回配信される可能性があるため、冪等な受信側を推奨します。
|
|
127
|
+
|
|
128
|
+
## 発火するイベント
|
|
129
|
+
|
|
130
|
+
- `content.created` — 新規投稿(任意のステータス)
|
|
131
|
+
- `content.published` — ステータスが `draft` → `published` に変化、または公開済み投稿が挿入された
|
|
132
|
+
- `content.unpublished` — ステータスが `published` → `draft` に変化、または公開済み投稿が削除された
|
|
133
|
+
- `content.updated` — 任意の MODIFY(公開 / 非公開への遷移時にも `published` / `unpublished` と同時に発火)
|
|
134
|
+
- `content.deleted` — 投稿が削除された
|
package/README.md
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
> 日本語版: [README.ja.md](./README.ja.md)
|
|
2
|
+
>
|
|
3
|
+
|
|
1
4
|
# @ampless/plugin-webhook
|
|
2
5
|
|
|
3
6
|
POST ampless events to one or more external URLs.
|
|
4
7
|
|
|
5
8
|
> **Pre-release / alpha.** Breaking changes possible in any minor version until v1.0.
|
|
6
9
|
|
|
7
|
-
Runs in the **
|
|
10
|
+
Runs in the **trusted** Lambda so it can access the admin-managed signing secret for zero-deploy key rotation.
|
|
8
11
|
|
|
9
12
|
## Install
|
|
10
13
|
|
|
@@ -90,6 +93,34 @@ function verify(rawBody: string, signatureHeader: string, secret: string): boole
|
|
|
90
93
|
|
|
91
94
|
Always compute the HMAC over the **raw request body** before any JSON parsing, and use a constant-time comparison.
|
|
92
95
|
|
|
96
|
+
## Signing secret (admin-managed)
|
|
97
|
+
|
|
98
|
+
Since Phase 6a the webhook plugin supports an admin-managed signing secret that lets you rotate the HMAC key without redeploying.
|
|
99
|
+
|
|
100
|
+
### Setting the secret in the admin UI
|
|
101
|
+
|
|
102
|
+
1. Open `/admin/plugins/webhook` → **Secret settings**.
|
|
103
|
+
2. Enter your signing secret and click **Save**.
|
|
104
|
+
3. The new key is active within seconds — no `git push`, no Amplify rebuild.
|
|
105
|
+
|
|
106
|
+
### How the secret applies
|
|
107
|
+
|
|
108
|
+
- **Admin secret is set** → applied to **all** endpoints uniformly. This is the recommended production setup because it gives you a single place to rotate the key.
|
|
109
|
+
- **Admin secret is not set** → each endpoint falls back to its per-endpoint `secret` from the constructor options (see [Configure](#configure) below). Use this for initial setup before you've migrated to admin-managed.
|
|
110
|
+
|
|
111
|
+
The per-endpoint constructor `secret` is closure-private and is **never** included in the plugin manifest or any public artifact — it stays server-side by construction.
|
|
112
|
+
|
|
113
|
+
### Rotating the secret
|
|
114
|
+
|
|
115
|
+
1. Generate a new secret (e.g. `openssl rand -hex 32`).
|
|
116
|
+
2. Update your receiver to accept both old and new secrets for a brief window (dual-verify).
|
|
117
|
+
3. Paste the new secret in the admin UI → **Replace** → **Save**.
|
|
118
|
+
4. Once all in-flight webhooks have drained (~30 s), remove the old secret from your receiver.
|
|
119
|
+
|
|
120
|
+
### Verifying the signature on the receiver
|
|
121
|
+
|
|
122
|
+
See [Verifying the signature](#verifying-the-signature-nodejs) below for the receiver-side implementation — the verification code is identical regardless of whether the key came from the admin UI or from the constructor.
|
|
123
|
+
|
|
93
124
|
## Retry behavior
|
|
94
125
|
|
|
95
126
|
When any endpoint returns a non-2xx response (or times out), the plugin throws. The trust-level processor Lambda re-throws to SQS, which retries the message up to 3 times before moving it to the dead-letter queue. Idempotent receivers are recommended — the same event can be delivered more than once.
|
package/dist/index.d.ts
CHANGED
|
@@ -29,16 +29,21 @@ interface WebhookEndpoint {
|
|
|
29
29
|
timeoutMs?: number;
|
|
30
30
|
}
|
|
31
31
|
interface WebhookPluginOptions {
|
|
32
|
-
endpoints
|
|
33
|
-
/** Backwards-compatible single-endpoint shortcut. */
|
|
34
|
-
url?: string;
|
|
35
|
-
secret?: string;
|
|
32
|
+
endpoints: WebhookEndpoint[];
|
|
36
33
|
}
|
|
37
34
|
/**
|
|
38
35
|
* Webhook plugin. Posts a JSON envelope to one or more external URLs
|
|
39
|
-
* whenever a subscribed event fires. Runs in the
|
|
40
|
-
*
|
|
36
|
+
* whenever a subscribed event fires. Runs in the trusted Lambda so it
|
|
37
|
+
* can access the admin-managed signing secret via `ctx.secret()`.
|
|
38
|
+
*
|
|
39
|
+
* Secret priority (per dispatch call):
|
|
40
|
+
* 1. Admin-managed `signingSecret` (set from `/admin/plugins/webhook`):
|
|
41
|
+
* applied uniformly to **all** endpoints — enables zero-deploy key
|
|
42
|
+
* rotation.
|
|
43
|
+
* 2. Per-endpoint `secret` from the constructor options (closure-private
|
|
44
|
+
* fallback): used when the admin secret has not been saved yet.
|
|
45
|
+
* Recommended for initial setup; rotate to admin-managed in production.
|
|
41
46
|
*/
|
|
42
|
-
declare function webhookPlugin(options
|
|
47
|
+
declare function webhookPlugin(options: WebhookPluginOptions): AmplessPlugin;
|
|
43
48
|
|
|
44
49
|
export { type WebhookEndpoint, type WebhookPluginOptions, webhookPlugin as default, signPayload };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
definePlugin
|
|
4
|
+
} from "ampless";
|
|
3
5
|
|
|
4
6
|
// src/sign.ts
|
|
5
7
|
import { createHmac } from "crypto";
|
|
@@ -15,8 +17,8 @@ var ALL_CONTENT_EVENTS = [
|
|
|
15
17
|
"content.unpublished",
|
|
16
18
|
"content.deleted"
|
|
17
19
|
];
|
|
18
|
-
function webhookPlugin(options
|
|
19
|
-
const endpoints =
|
|
20
|
+
function webhookPlugin(options) {
|
|
21
|
+
const endpoints = options.endpoints;
|
|
20
22
|
const subscribed = /* @__PURE__ */ new Set();
|
|
21
23
|
for (const ep of endpoints) {
|
|
22
24
|
const events = ep.events ?? ALL_CONTENT_EVENTS;
|
|
@@ -24,31 +26,42 @@ function webhookPlugin(options = {}) {
|
|
|
24
26
|
}
|
|
25
27
|
const hooks = {};
|
|
26
28
|
for (const eventType of subscribed) {
|
|
27
|
-
hooks[eventType] = async (event) => {
|
|
28
|
-
await
|
|
29
|
+
hooks[eventType] = async (event, ctx) => {
|
|
30
|
+
const adminSecret = await ctx.secret("signingSecret");
|
|
31
|
+
await dispatch(endpoints, event, adminSecret);
|
|
29
32
|
};
|
|
30
33
|
}
|
|
31
34
|
return definePlugin({
|
|
32
35
|
name: "webhook",
|
|
36
|
+
packageName: "@ampless/plugin-webhook",
|
|
33
37
|
apiVersion: 1,
|
|
34
|
-
trust_level: "
|
|
38
|
+
trust_level: "trusted",
|
|
39
|
+
capabilities: ["eventHooks", "secretSettings"],
|
|
40
|
+
settings: {
|
|
41
|
+
secret: [
|
|
42
|
+
{
|
|
43
|
+
type: "text",
|
|
44
|
+
key: "signingSecret",
|
|
45
|
+
maxLength: 256,
|
|
46
|
+
label: { en: "Signing secret", ja: "\u7F72\u540D\u30B7\u30FC\u30AF\u30EC\u30C3\u30C8" },
|
|
47
|
+
description: {
|
|
48
|
+
en: "HMAC-SHA-256 signing secret applied to all webhook endpoints. When set, overrides the per-endpoint constructor secret. Rotate here without redeploying.",
|
|
49
|
+
ja: "\u3059\u3079\u3066\u306E Webhook \u30A8\u30F3\u30C9\u30DD\u30A4\u30F3\u30C8\u306B\u9069\u7528\u3059\u308B HMAC-SHA-256 \u7F72\u540D\u30B7\u30FC\u30AF\u30EC\u30C3\u30C8\u3002\u8A2D\u5B9A\u3059\u308B\u3068\u30B3\u30F3\u30B9\u30C8\u30E9\u30AF\u30BF\u5074\u306E per-endpoint secret \u3088\u308A\u512A\u5148\u3055\u308C\u307E\u3059\u3002\u518D\u30C7\u30D7\u30ED\u30A4\u4E0D\u8981\u3067\u3053\u3053\u304B\u3089\u66F4\u65B0\u3057\u3066\u304F\u3060\u3055\u3044\u3002"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
]
|
|
53
|
+
},
|
|
35
54
|
hooks
|
|
36
55
|
});
|
|
37
56
|
}
|
|
38
|
-
function
|
|
39
|
-
const list = [];
|
|
40
|
-
if (options.url) list.push({ url: options.url, secret: options.secret });
|
|
41
|
-
if (options.endpoints) list.push(...options.endpoints);
|
|
42
|
-
return list;
|
|
43
|
-
}
|
|
44
|
-
async function dispatch(endpoints, event) {
|
|
57
|
+
async function dispatch(endpoints, event, adminSecret) {
|
|
45
58
|
const body = JSON.stringify({
|
|
46
59
|
type: event.type,
|
|
47
60
|
payload: event.payload,
|
|
48
61
|
timestamp: event.timestamp
|
|
49
62
|
});
|
|
50
63
|
const results = await Promise.allSettled(
|
|
51
|
-
endpoints.filter((ep) => (ep.events ?? ALL_CONTENT_EVENTS).includes(event.type)).map((ep) => postOne(ep, body, event.type))
|
|
64
|
+
endpoints.filter((ep) => (ep.events ?? ALL_CONTENT_EVENTS).includes(event.type)).map((ep) => postOne(ep, body, event.type, adminSecret))
|
|
52
65
|
);
|
|
53
66
|
const failures = results.filter((r) => r.status === "rejected");
|
|
54
67
|
if (failures.length > 0) {
|
|
@@ -56,14 +69,15 @@ async function dispatch(endpoints, event) {
|
|
|
56
69
|
throw new Error(`webhook plugin: ${failures.length} endpoint(s) failed \u2014 ${messages}`);
|
|
57
70
|
}
|
|
58
71
|
}
|
|
59
|
-
async function postOne(endpoint, body, eventType) {
|
|
72
|
+
async function postOne(endpoint, body, eventType, adminSecret) {
|
|
60
73
|
const headers = {
|
|
61
74
|
"Content-Type": "application/json",
|
|
62
75
|
"X-Ampless-Event": eventType,
|
|
63
76
|
...endpoint.headers ?? {}
|
|
64
77
|
};
|
|
65
|
-
|
|
66
|
-
|
|
78
|
+
const effectiveSecret = adminSecret ?? endpoint.secret;
|
|
79
|
+
if (effectiveSecret) {
|
|
80
|
+
headers["X-Ampless-Signature"] = signPayload(effectiveSecret, body);
|
|
67
81
|
}
|
|
68
82
|
const controller = new AbortController();
|
|
69
83
|
const timer = setTimeout(() => controller.abort(), endpoint.timeoutMs ?? 5e3);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ampless/plugin-webhook",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.50",
|
|
4
4
|
"description": "Webhook plugin for ampless — POST events to external URLs",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
".": {
|
|
9
9
|
"import": "./dist/index.js",
|
|
10
10
|
"types": "./dist/index.d.ts"
|
|
11
|
-
}
|
|
11
|
+
},
|
|
12
|
+
"./package.json": "./package.json"
|
|
12
13
|
},
|
|
13
14
|
"files": [
|
|
14
15
|
"dist",
|
|
@@ -25,13 +26,27 @@
|
|
|
25
26
|
"homepage": "https://github.com/heavymoons/ampless/tree/main/packages/plugin-webhook#readme",
|
|
26
27
|
"bugs": "https://github.com/heavymoons/ampless/issues",
|
|
27
28
|
"dependencies": {
|
|
28
|
-
"ampless": "0.
|
|
29
|
+
"ampless": "1.0.0-alpha.49"
|
|
29
30
|
},
|
|
30
31
|
"keywords": [
|
|
31
32
|
"ampless",
|
|
32
33
|
"plugin",
|
|
34
|
+
"ampless-plugin",
|
|
33
35
|
"webhook"
|
|
34
36
|
],
|
|
37
|
+
"amplessPlugin": {
|
|
38
|
+
"apiVersion": 1,
|
|
39
|
+
"name": "webhook",
|
|
40
|
+
"trustLevel": "trusted",
|
|
41
|
+
"capabilities": [
|
|
42
|
+
"eventHooks",
|
|
43
|
+
"secretSettings"
|
|
44
|
+
],
|
|
45
|
+
"displayName": {
|
|
46
|
+
"en": "Webhook",
|
|
47
|
+
"ja": "Webhook"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
35
50
|
"scripts": {
|
|
36
51
|
"build": "tsup",
|
|
37
52
|
"dev": "tsup --watch",
|