create-ampless 1.0.0-alpha.107 → 1.0.0-alpha.110
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/dist/templates/_shared/amplify/data/get-published-post.js +13 -1
- package/dist/templates/_shared/amplify/data/list-posts-by-tag.js +15 -3
- package/dist/templates/_shared/amplify/data/list-published-posts.js +26 -15
- package/dist/templates/_shared/docs/plugin-author-guide.ja.md +256 -9
- package/dist/templates/_shared/docs/plugin-author-guide.md +297 -41
- package/dist/templates/_shared/package.json +14 -14
- package/package.json +1 -1
|
@@ -8,6 +8,13 @@ import { util } from '@aws-appsync/utils'
|
|
|
8
8
|
//
|
|
9
9
|
// Drafts are dropped in the response handler. If draft + published
|
|
10
10
|
// somehow share a slug we prefer the published row.
|
|
11
|
+
//
|
|
12
|
+
// Scheduled-publish: a `published` post is visible only when its
|
|
13
|
+
// publishedAt is absent (= immediate) OR <= now (= already live).
|
|
14
|
+
// When publishedAt is present and in the future the post is treated as
|
|
15
|
+
// "not yet live" and is hidden from the public site. This check is
|
|
16
|
+
// server-authoritative: the AppSync resolver enforces it on every read
|
|
17
|
+
// without relying on any client-side filtering.
|
|
11
18
|
export function request(ctx) {
|
|
12
19
|
const slug = ctx.args.slug
|
|
13
20
|
return {
|
|
@@ -25,6 +32,11 @@ export function request(ctx) {
|
|
|
25
32
|
export function response(ctx) {
|
|
26
33
|
if (ctx.error) util.error(ctx.error.message, ctx.error.type)
|
|
27
34
|
const items = ctx.result.items ?? []
|
|
28
|
-
const
|
|
35
|
+
const now = util.time.nowISO8601()
|
|
36
|
+
// Hide a published post only when publishedAt exists AND is in the future
|
|
37
|
+
// (scheduled). Missing publishedAt = immediate publish.
|
|
38
|
+
const published = items.find(
|
|
39
|
+
(i) => i.status === 'published' && (!i.publishedAt || i.publishedAt <= now)
|
|
40
|
+
)
|
|
29
41
|
return published ?? null
|
|
30
42
|
}
|
|
@@ -9,14 +9,26 @@ import { util } from '@aws-appsync/utils'
|
|
|
9
9
|
// the tag partition condition. Drafts never appear here because the
|
|
10
10
|
// admin client only writes PostTag rows for posts whose status is
|
|
11
11
|
// 'published'.
|
|
12
|
+
//
|
|
13
|
+
// Scheduled-publish: the SK is a composite of `${publishedAt}#${postId}`.
|
|
14
|
+
// ISO 8601 timestamps are fixed-width to millisecond precision, so lexical
|
|
15
|
+
// order == chronological order. We add an upper-bound SK condition
|
|
16
|
+
// `<= now + '#' + ''` to exclude PostTag rows whose publishedAt is in
|
|
17
|
+
// the future. U+FFFF sorts above every character that can appear in a real
|
|
18
|
+
// postId, so `now#` is strictly greater than `now#<any-postId>`:
|
|
19
|
+
// every postId suffix sharing the current-millisecond ISO prefix is
|
|
20
|
+
// included, while any SK whose timestamp prefix is later is excluded.
|
|
21
|
+
// (Written as an escape so this source file stays ASCII.)
|
|
12
22
|
export function request(ctx) {
|
|
13
23
|
const { tag, limit, nextToken } = ctx.args
|
|
24
|
+
const now = util.time.nowISO8601()
|
|
25
|
+
const upper = now + '#'
|
|
14
26
|
return {
|
|
15
27
|
operation: 'Query',
|
|
16
28
|
query: {
|
|
17
|
-
expression: '#tag = :tag',
|
|
18
|
-
expressionNames: { '#tag': 'tag' },
|
|
19
|
-
expressionValues: util.dynamodb.toMapValues({ ':tag': tag }),
|
|
29
|
+
expression: '#tag = :tag AND #pap <= :upper',
|
|
30
|
+
expressionNames: { '#tag': 'tag', '#pap': 'publishedAtPostId' },
|
|
31
|
+
expressionValues: util.dynamodb.toMapValues({ ':tag': tag, ':upper': upper }),
|
|
20
32
|
},
|
|
21
33
|
scanIndexForward: false, // newest first (SK descends)
|
|
22
34
|
limit: limit ?? 20,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { util } from '@aws-appsync/utils'
|
|
1
|
+
import { util, runtime } from '@aws-appsync/utils'
|
|
2
2
|
|
|
3
3
|
// AppSync JS resolver: list published posts, newest first.
|
|
4
4
|
//
|
|
@@ -8,29 +8,40 @@ import { util } from '@aws-appsync/utils'
|
|
|
8
8
|
// so a single Query reads only the `published` partition. Drafts never
|
|
9
9
|
// appear because the PK condition pins status='published'.
|
|
10
10
|
//
|
|
11
|
+
// Scheduled-publish: the upper bound of the SK range is always clamped
|
|
12
|
+
// to `now`. This ensures future-dated published posts (scheduled but not
|
|
13
|
+
// yet live) never appear in public listings. The upper bound is the lesser
|
|
14
|
+
// of `now` and any caller-supplied `to` argument.
|
|
15
|
+
//
|
|
16
|
+
// Reverse-range guard: if `from` is supplied and ends up > upper (e.g.
|
|
17
|
+
// the caller asks for a range entirely in the future), we return an empty
|
|
18
|
+
// result early via `runtime.earlyReturn` instead of issuing an inverted
|
|
19
|
+
// BETWEEN to DynamoDB (which DynamoDB rejects with a ValidationException).
|
|
20
|
+
//
|
|
11
21
|
// Date-range filtering (`from`, `to`) is pushed into the SK condition,
|
|
12
22
|
// so DynamoDB only reads the matching range. `nextToken` paginates
|
|
13
23
|
// without re-issuing a fresh query.
|
|
14
24
|
export function request(ctx) {
|
|
15
25
|
const { from, to, limit, nextToken } = ctx.args
|
|
26
|
+
const now = util.time.nowISO8601()
|
|
27
|
+
// Always clamp the upper bound to now so future-dated posts are excluded.
|
|
28
|
+
const upper = to && to < now ? to : now
|
|
29
|
+
|
|
30
|
+
// If `from` is later than `upper` the range is empty — skip the query
|
|
31
|
+
// rather than letting DynamoDB see an inverted BETWEEN / >= condition.
|
|
32
|
+
if (from && from > upper) {
|
|
33
|
+
return runtime.earlyReturn({ items: [], nextToken: null })
|
|
34
|
+
}
|
|
16
35
|
|
|
17
36
|
let keyExpression = '#status = :status'
|
|
18
|
-
const expressionNames = { '#status': 'status' }
|
|
19
|
-
const expressionValueMap = { ':status': 'published' }
|
|
37
|
+
const expressionNames = { '#status': 'status', '#publishedAt': 'publishedAt' }
|
|
38
|
+
const expressionValueMap = { ':status': 'published', ':upper': upper }
|
|
20
39
|
|
|
21
|
-
if (from
|
|
22
|
-
keyExpression += ' AND #publishedAt BETWEEN :from AND :
|
|
23
|
-
expressionNames['#publishedAt'] = 'publishedAt'
|
|
24
|
-
expressionValueMap[':from'] = from
|
|
25
|
-
expressionValueMap[':to'] = to
|
|
26
|
-
} else if (from) {
|
|
27
|
-
keyExpression += ' AND #publishedAt >= :from'
|
|
28
|
-
expressionNames['#publishedAt'] = 'publishedAt'
|
|
40
|
+
if (from) {
|
|
41
|
+
keyExpression += ' AND #publishedAt BETWEEN :from AND :upper'
|
|
29
42
|
expressionValueMap[':from'] = from
|
|
30
|
-
} else
|
|
31
|
-
keyExpression += ' AND #publishedAt <= :
|
|
32
|
-
expressionNames['#publishedAt'] = 'publishedAt'
|
|
33
|
-
expressionValueMap[':to'] = to
|
|
43
|
+
} else {
|
|
44
|
+
keyExpression += ' AND #publishedAt <= :upper'
|
|
34
45
|
}
|
|
35
46
|
|
|
36
47
|
return {
|
|
@@ -441,7 +441,7 @@ runtime は `body` を `sanitize-html` の厳格 allowlist で sanitize し(
|
|
|
441
441
|
- **`attrs` allowlist**: `data-*`、`crossorigin`、`referrerpolicy`、`integrity`、`fetchpriority`、`loading`、`sandbox`、`allow`、`allowfullscreen`。それ以外は dev warn 付きで drop
|
|
442
442
|
- **`inlineScript.id` は必須**。無いとプラグイン同士が似たスニペットを emit したときに dedup できず、dev warning も index 番号を指すだけで原因プラグインを特定できません
|
|
443
443
|
- **id 重複**: 最後の出現が勝ち。dev warning でどの key が重複したか表示されます
|
|
444
|
-
- **CSP nonce**: Phase 1
|
|
444
|
+
- **CSP nonce**: `inlineScript.nonce` と `script.nonce` は型として受け入れられます(`'auto'` は将来の runtime スタンプ用 sentinel。文字列リテラルも可)。Phase 1 予約: runtime はフィールドを受け入れますが描画要素には伝搬しません。詳細は下記の [CSP nonce(Phase 1 予約)](#csp-nonce-phase-1-) を参照。
|
|
445
445
|
- **strategy**: `afterInteractive` は外部 script に `async` を付ける。`lazyOnload` は `defer`。明示的な `async` / `defer` が常に勝ち。`beforeInteractive` は非対応
|
|
446
446
|
|
|
447
447
|
### runtime が描画する形
|
|
@@ -565,9 +565,124 @@ React 19 はさらに、クライアントコンポーネントのレンダー
|
|
|
565
565
|
|
|
566
566
|
---
|
|
567
567
|
|
|
568
|
+
## 6a. 予約投稿とコンテンツイベント
|
|
569
|
+
|
|
570
|
+
ampless は**予約投稿**をサポートしています。`status: 'published'` かつ `publishedAt` が未来の投稿は、その時刻まで公開読み出しから隠されます。`publishedAt` を過ぎると、サイトの自然なキャッシュ有効期限(デフォルト ≤ ~5 分)の範囲内で公開されます — 秒単位の正確なトリガーはありません。
|
|
571
|
+
|
|
572
|
+
### イベントは保存時に発火する(`publishedAt` のタイミングではない)
|
|
573
|
+
|
|
574
|
+
`content.published`(および `content.updated`)は DynamoDB Streams 経由で**投稿が保存されたとき**に emit されます。`publishedAt` が到来したときではありません。つまり、`content.published` に反応するプラグインは、投稿が未来日時の場合、**その投稿が公開される前**に実行されます。
|
|
575
|
+
|
|
576
|
+
現在の投稿一覧から公開アセットを再構築する trusted プラグイン(RSS、sitemap、JSON インデックスなど)にとってはこれは問題ありません — `listPublishedPosts()` が未来日時の投稿をすでに除外しているため、再生成されたアセットはその投稿が公開されるまでリストに含まれません。
|
|
577
|
+
|
|
578
|
+
一方、**外部通知プラグイン**(webhook、プッシュ通知、SNS 投稿など)には影響があります。投稿の URL が 404 を返しているまたはホームページにリダイレクトされている間に通知が配信されてしまいます。
|
|
579
|
+
|
|
580
|
+
### 推奨パターン: `publishedAt` でゲートする
|
|
581
|
+
|
|
582
|
+
ディスパッチの前に `event.payload.publishedAt` を確認します。投稿が未来日時の場合はスキップまたは保留します:
|
|
583
|
+
|
|
584
|
+
```ts
|
|
585
|
+
hooks: {
|
|
586
|
+
async 'content.published'(event, ctx) {
|
|
587
|
+
const { publishedAt } = event.payload
|
|
588
|
+
|
|
589
|
+
// 予約投稿には通知を送らない。イベントは保存時に発火するが、
|
|
590
|
+
// 投稿が公開されるのは publishedAt になってから。
|
|
591
|
+
if (publishedAt && new Date(publishedAt) > new Date()) {
|
|
592
|
+
return
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// 投稿は今すぐ公開状態 — 通知しても安全。
|
|
596
|
+
await sendWebhook(event.payload, ctx)
|
|
597
|
+
},
|
|
598
|
+
}
|
|
599
|
+
```
|
|
600
|
+
|
|
601
|
+
イベントペイロード内の `publishedAt` は UTC ISO 8601 文字列(`...Z`)です。`Date.now()` と比較する前に `new Date()` またはお好みの日付ライブラリでパースしてください。
|
|
602
|
+
|
|
603
|
+
### 今後の対応
|
|
604
|
+
|
|
605
|
+
`content.published` の発火を保存時ではなく `publishedAt` のタイミングに合わせる機能 — EventBridge Scheduler または DynamoDB TTL トリガー Lambda によるスケジューラーコンポーネントが必要 — は計画中の機能強化です。現リリースのスコープには含まれていません。それまでの間は上記のパターンが通知プラグインにおける推奨ガードです。
|
|
606
|
+
|
|
607
|
+
オペレーター視点からの `publishedAt` セマンティクスの全体像は [`docs/scheduled-publishing.md`](https://github.com/heavymoons/ampless/blob/main/docs/scheduled-publishing.ja.md) を参照してください。
|
|
608
|
+
|
|
609
|
+
---
|
|
610
|
+
|
|
611
|
+
### CSP nonce(Phase 1 予約)
|
|
612
|
+
|
|
613
|
+
CSP(Content Security Policy)は本番公開サイトのほぼ必須要件です。nonce 伝搬を後から追加した場合に既存プラグインが一斉に動かなくなるのを防ぐため、ampless は今のうちに API サーフェスだけ予約しておきます(Phase 1 は完全 no-op)。
|
|
614
|
+
|
|
615
|
+
3 層設計:
|
|
616
|
+
|
|
617
|
+
1. **`ctx.cspNonce?: string`** — `PluginPublicRenderContext` の型に予約済み。今のところ常に `undefined`。runtime はこのフィールドをまだ populate しません。middleware/SSR nonce threading は将来の CSP RFP とともに landing します。
|
|
618
|
+
|
|
619
|
+
2. **`descriptor.nonce: 'auto' | string`** — `inlineScript` と `script` の両 descriptor variant の型で受け入れられます。`'auto'` は将来の runtime スタンプ用 sentinel。その他の文字列は明示的リテラル。`undefined` は `nonce` 属性を emit しない(デフォルト、非 CSP サイトと後方互換)。Phase 1: runtime は受け入れますが伝搬しません。`nonce: 'auto'` を今日宣言することは前方互換性のヒントであり、描画 HTML を変更しません。
|
|
620
|
+
|
|
621
|
+
3. **`'cspReady'` capability** — name-only の declarative バッジ。将来の admin UI / サニティチェックの対象になります。Phase 1 では runtime の cross-check や enforcement は一切なし。
|
|
622
|
+
|
|
623
|
+
**対応方法:**
|
|
624
|
+
|
|
625
|
+
```ts
|
|
626
|
+
// src/index.ts
|
|
627
|
+
definePlugin({
|
|
628
|
+
name: 'my-plugin',
|
|
629
|
+
apiVersion: 1,
|
|
630
|
+
trust_level: 'untrusted',
|
|
631
|
+
capabilities: ['publicHead', 'cspReady'],
|
|
632
|
+
publicHead: () => [{
|
|
633
|
+
type: 'inlineScript',
|
|
634
|
+
id: 'my-snippet',
|
|
635
|
+
body: '...',
|
|
636
|
+
nonce: 'auto', // 前方互換性ヒント。Phase 1 では効果なし
|
|
637
|
+
}],
|
|
638
|
+
})
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
npm 公開のスタンドアロンプラグインの場合は、`package.json#amplessPlugin.capabilities` も合わせて更新してください — static manifest と factory return value が一致しない場合、runtime cross-check が警告を出します:
|
|
642
|
+
|
|
643
|
+
```json
|
|
644
|
+
{
|
|
645
|
+
"amplessPlugin": {
|
|
646
|
+
"apiVersion": 1,
|
|
647
|
+
"name": "my-plugin",
|
|
648
|
+
"trustLevel": "untrusted",
|
|
649
|
+
"capabilities": ["publicHead", "cspReady"]
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
**`'cspReady'` の意味:**
|
|
655
|
+
|
|
656
|
+
- サイト全体の CSP 適合は middleware / レスポンスヘッダー / runtime が制御しない他の inline コンテンツにも依存します。
|
|
657
|
+
- middleware-driven nonce threading PR が landing した後は、`nonce: 'auto'` を持つ plugin-supplied script が runtime nonce スタンプの候補になります。
|
|
658
|
+
- `'cspReady'` は `create-ampless plugin --capabilities` には表示されません — reserved capability であり、scaffold はアクティブな enforcement を示唆しないよう除外しています。
|
|
659
|
+
|
|
660
|
+
---
|
|
661
|
+
|
|
568
662
|
## 7. 非同期イベントフック
|
|
569
663
|
|
|
570
|
-
`hooks` は SQS から到着したイベントを trust_level に対応する processor Lambda が受けて実行します。
|
|
664
|
+
`hooks` は SQS から到着したイベントを trust_level に対応する processor Lambda が受けて実行します。
|
|
665
|
+
|
|
666
|
+
### 戻り値の予約
|
|
667
|
+
|
|
668
|
+
`PluginEventHandler` の戻り値型は `Promise<void | PluginHookResult>`。
|
|
669
|
+
runtime は現状この戻り値を完全に無視する — 既存 plugin が
|
|
670
|
+
`Promise<void>` を返す形は migration 不要で動き続ける。
|
|
671
|
+
`PluginHookResult` は将来の directive (最初の用例として有力:
|
|
672
|
+
`metrics?: Record<string, number>` による observability emission) のた
|
|
673
|
+
めの予約で、今宣言するのは forward-compatibility のヒントとして
|
|
674
|
+
の扱い。注意: rewrite / cancel 系 directive は本 widening だけで
|
|
675
|
+
は有効化されない — `before:*` event の plugin 配線と payload 拡張
|
|
676
|
+
が別 PR で必要。
|
|
677
|
+
|
|
678
|
+
`PluginHookResult` には private な `__amplessPluginHookResult`
|
|
679
|
+
marker が付いており、union が `Promise<string>` / `Promise<number>`
|
|
680
|
+
等の無関係な promise を silently 受け入れないようになっている —
|
|
681
|
+
plugin 作者がこの marker を明示的に設定する必要は無い。
|
|
682
|
+
|
|
683
|
+
### Runtime context
|
|
684
|
+
|
|
685
|
+
runtime context (`ctx`) の中身:
|
|
571
686
|
|
|
572
687
|
```ts
|
|
573
688
|
interface PluginRuntimeContext {
|
|
@@ -706,14 +821,146 @@ stored 値 (validated)
|
|
|
706
821
|
|
|
707
822
|
Secret settings を使うと、trusted プラグインが認証情報 (Webhook 署名 secret・SMTP パスワード・外部 API トークン等) を admin UI 経由で保存・ローテーションできます。**公開サイトやブラウザ側コードに値が流れることはありません**。
|
|
708
823
|
|
|
709
|
-
|
|
824
|
+
### なぜ `settings.public` と API が違うのか
|
|
825
|
+
|
|
826
|
+
`settings.public` の値は公開 runtime に流れる設計です。`public/site-settings.json` にミラーされ、`ctx.setting()` で sync render surface から読めます。analytics の measurementId などには適切ですが、Webhook 署名 secret には絶対に使えません。
|
|
827
|
+
|
|
828
|
+
`settings.secret` はストレージモデルが構造的に異なります:
|
|
829
|
+
|
|
830
|
+
- KvStore とは **別テーブル** の `PluginSecret` DynamoDB テーブルに保存。admin/editor Cognito ユーザーは AppSync 経由でこのテーブルに**直接アクセスできない** — すべての書き込みは `setPluginSecret` mutation を通じて `plugin-secret-handler` Lambda 経由で行われる。
|
|
831
|
+
- 値は保存前に **AES-256-GCM 暗号化**される。admin ブラウザが plaintext を Lambda に TLS 経由で送信 → Lambda がバリデーション + `process.env.PLUGIN_SECRET_ENCRYPTION_KEY`(CDK が `amplify/secrets/encryption-key.ts` から注入)から鍵を読み取り + 暗号化 → ciphertext のみを DDB に書き込む。平文は DynamoDB に保存されず、ブラウザにも返らない。
|
|
832
|
+
- **脅威モデル(Phase 6a v2.2)**:
|
|
833
|
+
|
|
834
|
+
| 脅威 | 状態 |
|
|
835
|
+
|---|---|
|
|
836
|
+
| PluginSecret テーブルを閲覧する AWS Console オペレータ | ✓ 対策済み — ciphertext のみ、DDB に鍵なし |
|
|
837
|
+
| ソースリポジトリ / デプロイアーティファクトへのアクセス | ⚠ 対策なし — 鍵は `amplify/secrets/encryption-key.ts` に存在。リポジトリを private にし、アーティファクトアクセスを制限すること |
|
|
838
|
+
| 同一 Lambda 内の悪意ある trusted plugin | ✗ 対策なし — `process.env.PLUGIN_SECRET_ENCRYPTION_KEY` はプラグインコードから読める。真の分離 = per-plugin Lambda(privileged tier, ロードマップ) |
|
|
839
|
+
| S3 mirror 漏洩 | ✓ 対策済み — PluginSecret テーブルは mirror されない |
|
|
840
|
+
|
|
841
|
+
- trusted-processor Lambda が `node:crypto` で復号する。`ctx.secret<T>(key)` は平文 string を返す(ciphertext ではない)。
|
|
842
|
+
- S3 mirror 経路に絶対に流れない(mirror は KvStore のみを query する)。
|
|
843
|
+
- 公開 render surface (`publicHead` など) からは読めない。
|
|
844
|
+
- **field manifest 検証の範囲**: admin クライアントは UX フィードバック目的で `pattern` / `maxLength` / `required` を検証するが、Lambda 側は **汎用の 10,000 文字ハードキャップと安全文字サニタイザのみ**を強制する。admin/editor が AppSync mutation を直接呼ぶと field 単位の制約は迂回できる。設計上意図したもので、admin/editor は secret 設定を許可された信頼されたオペレータと位置づけている。manifest チェックは UX ガイダンスであり、セキュリティ境界ではない。
|
|
845
|
+
|
|
846
|
+
### 要件
|
|
847
|
+
|
|
848
|
+
`settings.secret` には 3 つの要件があります:
|
|
849
|
+
|
|
850
|
+
1. `trust_level: 'trusted'` — untrusted Lambda には PluginSecret table への DDB read 権限がない。他の trust level で宣言すると `definePlugin()` 時に throw する。
|
|
851
|
+
2. `'secretSettings'` を `capabilities` に含める — admin UI や将来の allow-list から capability を参照できるようにするため必須。省略すると console.warn(`'schema'` vs `publicBodyForPost` の不整合パターンと同じ)。
|
|
852
|
+
3. **鍵の初回セットアップ** — プロジェクトルートで実行:
|
|
853
|
+
```sh
|
|
854
|
+
npx create-ampless setup-encryption-key
|
|
855
|
+
```
|
|
856
|
+
32 バイトのランダムな鍵を生成し、`amplify/secrets/encryption-key.ts` に書き込む。AWS 認証情報不要 — ローカルファイル操作のみ。
|
|
857
|
+
|
|
858
|
+
次に `amplify/backend.ts` でその定数を import し、`defineAmplessBackend({ pluginSecretEncryptionKey })` に渡す。その後デプロイ(またはサンドボックス再起動)して Lambda env var に注入する。
|
|
859
|
+
|
|
860
|
+
public リポジトリの場合は `--gitignore` を渡してバージョン管理から除外し、鍵を別途配布する。
|
|
861
|
+
|
|
862
|
+
### Dual-write 整合性
|
|
863
|
+
|
|
864
|
+
`setPluginSecret` と `clearPluginSecret` は各操作で **2 テーブル**に連続して書き込む: `PluginSecret`(ciphertext)と `PluginSecretIndicator`(存在タイムスタンプ)。2 回目の書き込みが失敗した場合、テーブルは以下の予測可能な状態になる:
|
|
865
|
+
|
|
866
|
+
| 障害ポイント | `PluginSecret` | `PluginSecretIndicator` | `ctx.secret()` | `hasPluginSecret()` |
|
|
867
|
+
|---|---|---|---|---|
|
|
868
|
+
| **set**: indicator PutItem 失敗 | ciphertext 存在 | 不在 | plaintext を返す ✓ | `false`(UI: 「未保存」) |
|
|
869
|
+
| **clear**: indicator DeleteItem 失敗 | 不在 | stale(古いタイムスタンプ) | `undefined` ✓ | `true`(UI: 「保存済み」) |
|
|
870
|
+
|
|
871
|
+
clear パスの失敗は「安全側」: secret は発火しなくなるが、UI は一時的に「保存済み」と表示する。set パスの失敗は軽微な UI 不整合: secret は機能するが、存在インジケータはリトライが成功するまで不在となる。
|
|
872
|
+
|
|
873
|
+
### secret フィールドの宣言
|
|
874
|
+
|
|
875
|
+
```ts
|
|
876
|
+
import { definePlugin } from 'ampless'
|
|
877
|
+
|
|
878
|
+
export default function webhookPlugin(opts?: { signingSecret?: string }) {
|
|
879
|
+
// constructor から渡された secret は closure-private な fallback として保持。
|
|
880
|
+
// manifest にも descriptor にも出さない。
|
|
881
|
+
const constructorSecret = opts?.signingSecret
|
|
882
|
+
|
|
883
|
+
return definePlugin({
|
|
884
|
+
name: 'webhook',
|
|
885
|
+
apiVersion: 1,
|
|
886
|
+
trust_level: 'trusted',
|
|
887
|
+
capabilities: ['eventHooks', 'secretSettings'],
|
|
888
|
+
settings: {
|
|
889
|
+
secret: [
|
|
890
|
+
{
|
|
891
|
+
type: 'text',
|
|
892
|
+
key: 'signingSecret',
|
|
893
|
+
label: { en: 'Webhook signing secret', ja: 'Webhook 署名 secret' },
|
|
894
|
+
maxLength: 256,
|
|
895
|
+
required: false,
|
|
896
|
+
// `default` は型レベルで除外されている。closure-private fallback を使うこと。
|
|
897
|
+
},
|
|
898
|
+
],
|
|
899
|
+
},
|
|
900
|
+
hooks: {
|
|
901
|
+
async 'content.published'(event, ctx) {
|
|
902
|
+
// ctx.secret() は PluginSecret DDB table から読む。
|
|
903
|
+
// admin が未保存なら undefined を返す。
|
|
904
|
+
const storedSecret = await ctx.secret<string>('signingSecret')
|
|
905
|
+
|
|
906
|
+
// closure-private fallback: admin が未保存の場合 constructor 引数を使う。
|
|
907
|
+
// これで既存サイトとの後方互換を維持できる。
|
|
908
|
+
const secret = storedSecret ?? constructorSecret
|
|
909
|
+
if (!secret) return
|
|
910
|
+
|
|
911
|
+
// ... secret で署名して POST
|
|
912
|
+
},
|
|
913
|
+
},
|
|
914
|
+
})
|
|
915
|
+
}
|
|
916
|
+
```
|
|
917
|
+
|
|
918
|
+
### 重要: secret フィールドに `default` を書かない
|
|
919
|
+
|
|
920
|
+
`PluginSecretField` 型は `Omit<PluginTextField, 'default'> | Omit<PluginTextareaField, 'default'>` として定義されており、**`default` プロパティは型レベルで除去**されています。追加しようとすると TypeScript がエラーを出します。
|
|
921
|
+
|
|
922
|
+
理由: `default` は admin UI のフォーム props (ブラウザに送出される)、静的 manifest の cross-check、JS bundle など複数の経路で漏洩します。認証情報に使えない設計です。
|
|
923
|
+
|
|
924
|
+
fallback 値がある場合は、プラグイン factory 関数の closure-private 変数として保持してください:
|
|
925
|
+
|
|
926
|
+
```ts
|
|
927
|
+
// ✓ 正解 — closure-private、manifest に出さない
|
|
928
|
+
const constructorSecret = opts?.signingSecret
|
|
929
|
+
|
|
930
|
+
// ✗ 誤り — TypeScript エラー、さらに browser にも漏れる
|
|
931
|
+
settings: {
|
|
932
|
+
secret: [{
|
|
933
|
+
type: 'text',
|
|
934
|
+
key: 'signingSecret',
|
|
935
|
+
label: 'Secret',
|
|
936
|
+
default: opts?.signingSecret, // ← TS compile error
|
|
937
|
+
}],
|
|
938
|
+
}
|
|
939
|
+
```
|
|
940
|
+
|
|
941
|
+
### secret の読み出し: `ctx.secret<T>(key)`
|
|
942
|
+
|
|
943
|
+
`ctx.secret<T>(key)` は trusted hook handler 内でのみ利用できます (`processor-trusted.ts` が注入)。シグネチャ:
|
|
944
|
+
|
|
945
|
+
```ts
|
|
946
|
+
ctx.secret<T = string>(key: string): Promise<T | undefined>
|
|
947
|
+
```
|
|
948
|
+
|
|
949
|
+
- admin が未保存なら `undefined` を返す。
|
|
950
|
+
- `T` は convenience cast (ctx.setting と同じ)。値は常に string として保存される。
|
|
951
|
+
- 結果は per-invocation キャッシュされる。同 batch 内で同キーを 2 回呼んでも DDB 呼び出しと復号処理は 1 回ずつ。暗号化キーは Lambda env var から cold-start 時に decode される(DDB への余分な fetch は不要)。
|
|
952
|
+
- キャッシュされる値は **復号済みの平文** — ciphertext ではない。2 回目の呼び出しで再復号は発生しない。
|
|
953
|
+
- cache key は namespace 化される: `${instanceId ?? name}:${fieldKey}`。異なる plugin instance が同名フィールドを持っても混線しない。
|
|
954
|
+
|
|
955
|
+
### admin UI
|
|
956
|
+
|
|
957
|
+
`settings.secret` を宣言すると、admin plugin settings ページの public フィールドの下に **Secret settings** セクションが表示されます。各フィールド:
|
|
958
|
+
|
|
959
|
+
- **未保存**: 通常テキスト入力 + Save ボタン。
|
|
960
|
+
- **保存済み**: マスク表示 `••••••••` + Replace + Clear ボタン。値は絶対に取得・表示されない。
|
|
961
|
+
- **編集中**: Replace クリック後 — 新値入力 + Save + Cancel。
|
|
710
962
|
|
|
711
|
-
|
|
712
|
-
- フィールド型は `PluginSecretField` = `Omit<PluginTextField, 'default'> | Omit<PluginTextareaField, 'default'>`。`default` は型レベルで禁止。fallback は closure-private 変数で保持。
|
|
713
|
-
- admin ブラウザは AppSync mutation (`setPluginSecret`) 経由で書き込む → `plugin-secret-handler` Lambda が env var から鍵取得・AES-256-GCM 暗号化・DDB PutItem。平文は DDB に保存されずブラウザにも返らない。`ctx.secret<T>(key)` は trusted Lambda が復号した平文を返す(`process.env.PLUGIN_SECRET_ENCRYPTION_KEY` を使用; DDB に鍵は保存しない)。per-invocation でキャッシュ。
|
|
714
|
-
- admin UI が「保存済み」表示 (`••••••••`) + Replace + Clear を提供。値は取得・表示されない。
|
|
715
|
-
- **Dual-write 整合性**: set/clear は 2 テーブルに連続して書く。2 回目失敗時 — set パス部分失敗は「secret 機能する・indicator 不在(UI: 未保存誤表示)」; clear パス部分失敗は「secret 削除済・indicator stale(UI: 保存済み誤表示、secret は発火しない安全側)」。
|
|
716
|
-
- **field manifest 検証の範囲**: admin クライアントは UX フィードバック用に `pattern` / `maxLength` / `required` を検証するが、Lambda は **汎用 10,000 文字キャップと安全文字サニタイザのみ**を強制。admin/editor が AppSync mutation を直接呼ぶと field 単位の制約は迂回可能 — admin/editor は信頼されたオペレータと扱う設計のため、manifest チェックはセキュリティ境界ではなく UX ガイダンス。
|
|
963
|
+
admin は再デプロイなしにいつでも secret をローテーションできます。保存後 ~5〜10 秒以内に次の trusted Lambda 実行から新値が使われます。
|
|
717
964
|
|
|
718
965
|
---
|
|
719
966
|
|
|
@@ -540,8 +540,12 @@ a console warning** and never rendered.
|
|
|
540
540
|
index numbers no one can map back to a plugin.
|
|
541
541
|
- **Duplicate `id`**: the last occurrence wins. A dev warning prints
|
|
542
542
|
which key was duplicated.
|
|
543
|
-
- **CSP nonce**:
|
|
544
|
-
|
|
543
|
+
- **CSP nonce**: `inlineScript.nonce` and `script.nonce` are accepted
|
|
544
|
+
by the type (`'auto'` is the sentinel for future runtime stamping;
|
|
545
|
+
any string is an explicit literal). Phase 1 reservation: the runtime
|
|
546
|
+
accepts the field but does not propagate it to the rendered element.
|
|
547
|
+
See [CSP nonce (Phase 1 reservation)](#csp-nonce-phase-1-reservation)
|
|
548
|
+
below.
|
|
545
549
|
- **Strategy**: `afterInteractive` adds `async` to external scripts.
|
|
546
550
|
`lazyOnload` adds `defer`. Explicit `async` / `defer` always
|
|
547
551
|
wins. `beforeInteractive` is not supported.
|
|
@@ -717,10 +721,167 @@ nothing races against hydration.
|
|
|
717
721
|
|
|
718
722
|
---
|
|
719
723
|
|
|
724
|
+
## 6a. Scheduled posts and content events
|
|
725
|
+
|
|
726
|
+
ampless supports **scheduled publishing**: a post with `status:
|
|
727
|
+
'published'` and a future `publishedAt` is hidden from all public
|
|
728
|
+
reads until that time. Once `publishedAt` arrives, the post becomes
|
|
729
|
+
visible within the site's natural cache window (≤ ~5 minutes by
|
|
730
|
+
default) — there is no exact-time trigger.
|
|
731
|
+
|
|
732
|
+
### Events fire at save time, not at `publishedAt`
|
|
733
|
+
|
|
734
|
+
`content.published` (and `content.updated`) are emitted via DynamoDB
|
|
735
|
+
Streams **when the post is saved**, not when `publishedAt` arrives.
|
|
736
|
+
This means a plugin that reacts to `content.published` will run
|
|
737
|
+
**before the post is publicly visible** when the post is future-dated.
|
|
738
|
+
|
|
739
|
+
For trusted plugins that rebuild public assets from the current post
|
|
740
|
+
list (RSS, sitemap, JSON indexes), this is harmless — `listPublishedPosts()`
|
|
741
|
+
already filters out future-dated posts, so the regenerated asset
|
|
742
|
+
simply omits the scheduled post until it goes live.
|
|
743
|
+
|
|
744
|
+
For **outbound-notification plugins** (webhook, push notification,
|
|
745
|
+
social post) the early fire matters: the notification will be
|
|
746
|
+
delivered to subscribers while the post URL still returns 404 or
|
|
747
|
+
redirects to the home page.
|
|
748
|
+
|
|
749
|
+
### Recommended pattern: gate on `publishedAt`
|
|
750
|
+
|
|
751
|
+
Check `event.payload.publishedAt` before dispatching. Skip or defer
|
|
752
|
+
when the post is future-dated:
|
|
753
|
+
|
|
754
|
+
```ts
|
|
755
|
+
hooks: {
|
|
756
|
+
async 'content.published'(event, ctx) {
|
|
757
|
+
const { publishedAt } = event.payload
|
|
758
|
+
|
|
759
|
+
// Skip notification for future-scheduled posts. The event fires
|
|
760
|
+
// at save time, but the post won't be public until publishedAt.
|
|
761
|
+
if (publishedAt && new Date(publishedAt) > new Date()) {
|
|
762
|
+
return
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Post is live now — safe to notify.
|
|
766
|
+
await sendWebhook(event.payload, ctx)
|
|
767
|
+
},
|
|
768
|
+
}
|
|
769
|
+
```
|
|
770
|
+
|
|
771
|
+
The `publishedAt` value in the event payload is a UTC ISO 8601 string
|
|
772
|
+
(`...Z`). Parse it with `new Date()` or your preferred date library
|
|
773
|
+
before comparing against `Date.now()`.
|
|
774
|
+
|
|
775
|
+
### Future work
|
|
776
|
+
|
|
777
|
+
Aligning event emission with the scheduled time — so `content.published`
|
|
778
|
+
fires at `publishedAt` rather than at save time — is a planned
|
|
779
|
+
enhancement. It requires a scheduler component (EventBridge Scheduler
|
|
780
|
+
or a DynamoDB TTL-triggered Lambda) and is not yet in scope for the
|
|
781
|
+
current release. Until then, the pattern above is the recommended
|
|
782
|
+
guard for notification plugins.
|
|
783
|
+
|
|
784
|
+
For a full description of `publishedAt` semantics from the operator's
|
|
785
|
+
perspective, see [`docs/scheduled-publishing.md`](https://github.com/heavymoons/ampless/blob/main/docs/scheduled-publishing.md).
|
|
786
|
+
|
|
787
|
+
---
|
|
788
|
+
|
|
789
|
+
### CSP nonce (Phase 1 reservation)
|
|
790
|
+
|
|
791
|
+
Content Security Policy (CSP) is a near-mandatory requirement for production
|
|
792
|
+
sites. To avoid breaking every plugin at once when nonce propagation lands,
|
|
793
|
+
ampless reserves the API surface today (Phase 1 no-op) so plugins can opt in
|
|
794
|
+
ahead of time.
|
|
795
|
+
|
|
796
|
+
The 3-layer design:
|
|
797
|
+
|
|
798
|
+
1. **`ctx.cspNonce?: string`** on `PluginPublicRenderContext` — type-reserved
|
|
799
|
+
on the interface; always `undefined` today. The runtime does not populate
|
|
800
|
+
this field yet; reads resolve to `undefined`. Middleware/SSR nonce threading
|
|
801
|
+
lands with the future CSP RFP.
|
|
802
|
+
|
|
803
|
+
2. **`descriptor.nonce: 'auto' | string`** — accepted by the type on both
|
|
804
|
+
`inlineScript` and `script` descriptor variants. `'auto'` is the sentinel
|
|
805
|
+
for future runtime stamping; any other string is an explicit literal;
|
|
806
|
+
`undefined` emits no `nonce` attribute (default, backward-compatible).
|
|
807
|
+
Phase 1: the runtime accepts but does not propagate it. Declaring
|
|
808
|
+
`nonce: 'auto'` today is a forward-compatibility hint and does not change
|
|
809
|
+
the rendered HTML.
|
|
810
|
+
|
|
811
|
+
3. **`'cspReady'` capability** — a name-only declarative badge. Declaring it
|
|
812
|
+
signals intent; future admin UI / sanity checks may surface it. No runtime
|
|
813
|
+
cross-check or enforcement exists in Phase 1.
|
|
814
|
+
|
|
815
|
+
**How to be ready:**
|
|
816
|
+
|
|
817
|
+
```ts
|
|
818
|
+
// src/index.ts
|
|
819
|
+
definePlugin({
|
|
820
|
+
name: 'my-plugin',
|
|
821
|
+
apiVersion: 1,
|
|
822
|
+
trust_level: 'untrusted',
|
|
823
|
+
capabilities: ['publicHead', 'cspReady'],
|
|
824
|
+
publicHead: () => [{
|
|
825
|
+
type: 'inlineScript',
|
|
826
|
+
id: 'my-snippet',
|
|
827
|
+
body: '...',
|
|
828
|
+
nonce: 'auto', // forward-compat hint; no effect in Phase 1
|
|
829
|
+
}],
|
|
830
|
+
})
|
|
831
|
+
```
|
|
832
|
+
|
|
833
|
+
For standalone npm-published plugins, also update `package.json#amplessPlugin.capabilities`
|
|
834
|
+
to match — the runtime cross-check warns on disagreement between the static
|
|
835
|
+
manifest and the factory return value:
|
|
836
|
+
|
|
837
|
+
```json
|
|
838
|
+
{
|
|
839
|
+
"amplessPlugin": {
|
|
840
|
+
"apiVersion": 1,
|
|
841
|
+
"name": "my-plugin",
|
|
842
|
+
"trustLevel": "untrusted",
|
|
843
|
+
"capabilities": ["publicHead", "cspReady"]
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
```
|
|
847
|
+
|
|
848
|
+
**What "cspReady" means:**
|
|
849
|
+
|
|
850
|
+
- Site-level CSP compliance depends on middleware / response headers / other
|
|
851
|
+
inline content the runtime does not control.
|
|
852
|
+
- Once the middleware-driven nonce threading PR lands, plugin-supplied scripts
|
|
853
|
+
that carry `nonce: 'auto'` will become candidates for runtime nonce stamping.
|
|
854
|
+
- `'cspReady'` does **not** appear in `create-ampless plugin --capabilities`
|
|
855
|
+
output — it is a reserved capability and the scaffold excludes it to avoid
|
|
856
|
+
implying active enforcement.
|
|
857
|
+
|
|
858
|
+
---
|
|
859
|
+
|
|
720
860
|
## 7. Async event hooks
|
|
721
861
|
|
|
722
862
|
`hooks` runs inside the trust_level-matched processor Lambda when an
|
|
723
|
-
event arrives via SQS.
|
|
863
|
+
event arrives via SQS.
|
|
864
|
+
|
|
865
|
+
### Return value reservation
|
|
866
|
+
|
|
867
|
+
`PluginEventHandler` returns `Promise<void | PluginHookResult>`. The
|
|
868
|
+
runtime currently ignores the return value entirely — existing
|
|
869
|
+
plugins returning `Promise<void>` keep working without migration.
|
|
870
|
+
`PluginHookResult` is reserved for a future directive (likely first:
|
|
871
|
+
`metrics?: Record<string, number>` for observability emission);
|
|
872
|
+
declaring it today is a forward-compatibility hint. Note: rewrite
|
|
873
|
+
or cancel directives are NOT enabled by this widening alone — they
|
|
874
|
+
require additional `before:*` event support and payload extensions
|
|
875
|
+
in separate PRs.
|
|
876
|
+
|
|
877
|
+
`PluginHookResult` carries a private `__amplessPluginHookResult`
|
|
878
|
+
marker so that the union does not silently accept unrelated promise
|
|
879
|
+
types (`Promise<string>` / `Promise<number>` etc.) — plugin authors
|
|
880
|
+
do not need to set this field.
|
|
881
|
+
|
|
882
|
+
### Runtime context
|
|
883
|
+
|
|
884
|
+
The runtime context (`ctx`) carries:
|
|
724
885
|
|
|
725
886
|
```ts
|
|
726
887
|
interface PluginRuntimeContext {
|
|
@@ -924,14 +1085,15 @@ etc. — but completely wrong for a webhook signing secret.
|
|
|
924
1085
|
|
|
925
1086
|
- Stored in the `PluginSecret` DynamoDB table (separate from KvStore).
|
|
926
1087
|
Admin/editor Cognito users have **no direct AppSync access** to this
|
|
927
|
-
table — all writes go through the `setPluginSecret` mutation
|
|
928
|
-
by the `plugin-secret-handler` Lambda.
|
|
1088
|
+
table — all writes go through the `setPluginSecret` mutation, which
|
|
1089
|
+
is backed by the `plugin-secret-handler` Lambda.
|
|
929
1090
|
- Values are **AES-256-GCM encrypted** before reaching DynamoDB.
|
|
930
1091
|
The admin browser sends the plaintext to the Lambda via AppSync
|
|
931
|
-
(TLS); the Lambda validates, reads the 32-byte
|
|
932
|
-
`process.env.PLUGIN_SECRET_ENCRYPTION_KEY`
|
|
933
|
-
`amplify/secrets/encryption-key.ts`),
|
|
934
|
-
|
|
1092
|
+
(TLS in transit); the Lambda validates it, reads the 32-byte
|
|
1093
|
+
encryption key from `process.env.PLUGIN_SECRET_ENCRYPTION_KEY`
|
|
1094
|
+
(injected by CDK from `amplify/secrets/encryption-key.ts`), encrypts
|
|
1095
|
+
the value, and writes only the ciphertext to DDB. The plaintext
|
|
1096
|
+
never rests in DynamoDB and never flows back to the browser.
|
|
935
1097
|
- **Threat model (Phase 6a v2.2)**:
|
|
936
1098
|
|
|
937
1099
|
| Threat | Status |
|
|
@@ -942,19 +1104,19 @@ etc. — but completely wrong for a webhook signing secret.
|
|
|
942
1104
|
| S3 mirror leak | ✓ defeated — PluginSecret table never mirrored. |
|
|
943
1105
|
|
|
944
1106
|
- The trusted-processor Lambda decrypts with `node:crypto` on read.
|
|
945
|
-
`ctx.secret<T>(key)` returns the plaintext string, never
|
|
1107
|
+
`ctx.secret<T>(key)` returns the plaintext string, never the
|
|
1108
|
+
ciphertext.
|
|
946
1109
|
- Never queried by the site-settings mirror path.
|
|
947
1110
|
- Never passed to any public-render surface
|
|
948
|
-
(`publicHead`, `publicBodyEnd`, `publicBodyForPost`,
|
|
949
|
-
`publicHtmlForPost`) — those surfaces only see `ctx.setting()`.
|
|
950
1111
|
- **Field-manifest validation scope**: the admin client validates
|
|
951
1112
|
`pattern` / `maxLength` / `required` for UX feedback, but the
|
|
952
|
-
Lambda only enforces a generic 10,000-character hard cap +
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
1113
|
+
Lambda only enforces a generic 10,000-character hard cap + safe-
|
|
1114
|
+
character sanitizer. An admin/editor calling the AppSync mutation
|
|
1115
|
+
directly can bypass field-level constraints — by design, since
|
|
1116
|
+
admin/editor are trusted operators authorised to set secrets.
|
|
1117
|
+
The manifest checks are UX guidance, not a security boundary.
|
|
1118
|
+
(`publicHead`, `publicBodyEnd`, `publicBodyForPost`,
|
|
1119
|
+
`publicHtmlForPost`) — those surfaces only see `ctx.setting()`.
|
|
958
1120
|
|
|
959
1121
|
### Requirements
|
|
960
1122
|
|
|
@@ -970,25 +1132,33 @@ etc. — but completely wrong for a webhook signing secret.
|
|
|
970
1132
|
```sh
|
|
971
1133
|
npx create-ampless setup-encryption-key
|
|
972
1134
|
```
|
|
973
|
-
|
|
974
|
-
`amplify/secrets/encryption-key.ts`. No AWS credentials
|
|
975
|
-
|
|
1135
|
+
This generates a cryptographically random 32-byte key and writes
|
|
1136
|
+
it to `amplify/secrets/encryption-key.ts`. No AWS credentials
|
|
1137
|
+
required — this is a local file operation only.
|
|
1138
|
+
|
|
1139
|
+
Then import the constant in `amplify/backend.ts` and pass it to
|
|
976
1140
|
`defineAmplessBackend({ pluginSecretEncryptionKey })`. Redeploy (or
|
|
977
|
-
restart the sandbox) to inject the key into Lambda env vars.
|
|
978
|
-
|
|
1141
|
+
restart the sandbox) to inject the key into the Lambda env vars.
|
|
1142
|
+
|
|
1143
|
+
For public repos, pass `--gitignore` to exclude the key file from
|
|
1144
|
+
version control and distribute the key separately.
|
|
979
1145
|
|
|
980
1146
|
### Dual-write integrity
|
|
981
1147
|
|
|
982
|
-
The `setPluginSecret` and `clearPluginSecret` operations each write
|
|
983
|
-
**two DynamoDB tables** in sequence
|
|
984
|
-
|
|
1148
|
+
The `setPluginSecret` and `clearPluginSecret` operations each write
|
|
1149
|
+
to **two DynamoDB tables** in sequence: `PluginSecret` (ciphertext)
|
|
1150
|
+
and `PluginSecretIndicator` (existence timestamp). If the second
|
|
1151
|
+
write fails, the tables are left in a predictable, documented state:
|
|
985
1152
|
|
|
986
|
-
| Failure | `PluginSecret` | `PluginSecretIndicator` | `ctx.secret()` | `hasPluginSecret()` |
|
|
1153
|
+
| Failure point | `PluginSecret` | `PluginSecretIndicator` | `ctx.secret()` | `hasPluginSecret()` |
|
|
987
1154
|
|---|---|---|---|---|
|
|
988
1155
|
| **set**: indicator PutItem fails | ciphertext present | absent | returns plaintext ✓ | `false` (UI: "not saved") |
|
|
989
|
-
| **clear**: indicator DeleteItem fails | absent | stale | `undefined` ✓ | `true` (UI: "saved") |
|
|
1156
|
+
| **clear**: indicator DeleteItem fails | absent | stale (old timestamp) | `undefined` ✓ | `true` (UI: "saved") |
|
|
990
1157
|
|
|
991
|
-
The clear-path failure is the "safe side": the secret stops firing
|
|
1158
|
+
The clear-path failure is the "safe side": the secret stops firing even
|
|
1159
|
+
though the UI briefly shows it as saved. The set-path failure is a minor
|
|
1160
|
+
UI inaccuracy: the secret is functional but the existence indicator is
|
|
1161
|
+
absent until a retry succeeds.
|
|
992
1162
|
|
|
993
1163
|
### Declaring secret fields
|
|
994
1164
|
|
|
@@ -1020,9 +1190,16 @@ export default function webhookPlugin(opts?: { signingSecret?: string }) {
|
|
|
1020
1190
|
},
|
|
1021
1191
|
hooks: {
|
|
1022
1192
|
async 'content.published'(event, ctx) {
|
|
1193
|
+
// ctx.secret() reads from PluginSecret DDB table.
|
|
1194
|
+
// Returns undefined when no value has been saved by admin yet.
|
|
1023
1195
|
const storedSecret = await ctx.secret<string>('signingSecret')
|
|
1196
|
+
|
|
1197
|
+
// Closure-private fallback: use the constructor argument when
|
|
1198
|
+
// the admin has not saved a value yet. This preserves backward
|
|
1199
|
+
// compatibility with sites that pass the secret at install time.
|
|
1024
1200
|
const secret = storedSecret ?? constructorSecret
|
|
1025
|
-
if (!secret) return
|
|
1201
|
+
if (!secret) return // no secret → skip signing
|
|
1202
|
+
|
|
1026
1203
|
// ... use secret to sign and POST
|
|
1027
1204
|
},
|
|
1028
1205
|
},
|
|
@@ -1034,25 +1211,104 @@ export default function webhookPlugin(opts?: { signingSecret?: string }) {
|
|
|
1034
1211
|
|
|
1035
1212
|
The type `PluginSecretField` is defined as `Omit<PluginTextField,
|
|
1036
1213
|
'default'> | Omit<PluginTextareaField, 'default'>` — the `default`
|
|
1037
|
-
property is **removed at the type level
|
|
1038
|
-
|
|
1214
|
+
property is **removed at the type level** and TypeScript will error
|
|
1215
|
+
if you try to add one.
|
|
1216
|
+
|
|
1217
|
+
This is intentional: `default` values propagate into admin UI form
|
|
1218
|
+
props (visible in the browser), static manifests cross-checked by
|
|
1219
|
+
the runtime, and JS bundles. For a credential, these are all leak
|
|
1220
|
+
paths.
|
|
1221
|
+
|
|
1222
|
+
If you have a fallback value (e.g. the constructor argument), keep
|
|
1223
|
+
it as a **closure-private variable** inside the plugin factory
|
|
1224
|
+
function — never in the manifest:
|
|
1225
|
+
|
|
1226
|
+
```ts
|
|
1227
|
+
// ✓ correct — closure-private, never in the manifest
|
|
1228
|
+
const constructorSecret = opts?.signingSecret
|
|
1229
|
+
|
|
1230
|
+
// ✗ wrong — TypeScript error, would also leak to browser
|
|
1231
|
+
settings: {
|
|
1232
|
+
secret: [{
|
|
1233
|
+
type: 'text',
|
|
1234
|
+
key: 'signingSecret',
|
|
1235
|
+
label: 'Secret',
|
|
1236
|
+
default: opts?.signingSecret, // ← TS compile error
|
|
1237
|
+
}],
|
|
1238
|
+
}
|
|
1239
|
+
```
|
|
1039
1240
|
|
|
1040
1241
|
### Reading secrets: `ctx.secret<T>(key)`
|
|
1041
1242
|
|
|
1042
|
-
`ctx.secret<T>(key)` is only available in trusted hook handlers
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1243
|
+
`ctx.secret<T>(key)` is only available in trusted hook handlers
|
|
1244
|
+
(injected by `processor-trusted.ts`). The signature is:
|
|
1245
|
+
|
|
1246
|
+
```ts
|
|
1247
|
+
ctx.secret<T = string>(key: string): Promise<T | undefined>
|
|
1248
|
+
```
|
|
1249
|
+
|
|
1250
|
+
- Returns `undefined` when no value has been saved by admin yet.
|
|
1251
|
+
- The generic `T` is a convenience cast (same as `ctx.setting<T>()`).
|
|
1252
|
+
Values are always stored as strings; `T` defaults to `string`.
|
|
1253
|
+
- Results are per-invocation cached. Calling `ctx.secret('key')`
|
|
1254
|
+
twice in the same hook batch costs one DDB round-trip (and one
|
|
1255
|
+
decrypt). The encryption key is decoded from the Lambda env var
|
|
1256
|
+
at cold-start (no extra DDB fetch).
|
|
1257
|
+
- Cache keys are namespaced: `${instanceId ?? name}:${fieldKey}`.
|
|
1258
|
+
Two plugin instances both declaring `'signingSecret'` never get
|
|
1259
|
+
each other's values.
|
|
1260
|
+
- The cached value is the **decrypted plaintext** — not the
|
|
1261
|
+
ciphertext. There is no redundant decrypt on repeated calls.
|
|
1049
1262
|
|
|
1050
1263
|
### Admin UI
|
|
1051
1264
|
|
|
1052
1265
|
When `settings.secret` is declared, the admin plugin settings page
|
|
1053
1266
|
renders a **Secret settings** section below the public fields.
|
|
1054
|
-
|
|
1055
|
-
|
|
1267
|
+
Each secret field shows:
|
|
1268
|
+
|
|
1269
|
+
- **Unset**: a plain text input + Save button.
|
|
1270
|
+
- **Stored**: a masked placeholder `••••••••` + Replace + Clear
|
|
1271
|
+
buttons. The actual value is never fetched or displayed.
|
|
1272
|
+
- **Editing**: after clicking Replace — new text input + Save + Cancel.
|
|
1273
|
+
|
|
1274
|
+
Admins can rotate a secret at any time without redeploying. The
|
|
1275
|
+
change takes effect on the next trusted-Lambda invocation (within
|
|
1276
|
+
~5–10 seconds of saving).
|
|
1277
|
+
|
|
1278
|
+
### Testing hooks that use `ctx.secret`
|
|
1279
|
+
|
|
1280
|
+
Mock `ctx.secret` alongside the other context methods:
|
|
1281
|
+
|
|
1282
|
+
```ts
|
|
1283
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
1284
|
+
import webhookPlugin from './index.js'
|
|
1285
|
+
import type { TrustedPluginRuntimeContext } from 'ampless'
|
|
1286
|
+
|
|
1287
|
+
function makeCtx(secrets: Record<string, string> = {}): TrustedPluginRuntimeContext {
|
|
1288
|
+
return {
|
|
1289
|
+
site: { name: 'Test', url: 'https://example.com' },
|
|
1290
|
+
listPublishedPosts: vi.fn().mockResolvedValue([]),
|
|
1291
|
+
writePublicAsset: vi.fn().mockResolvedValue(''),
|
|
1292
|
+
secret: vi.fn().mockImplementation(async (key: string) => secrets[key]),
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
describe('webhookPlugin signing', () => {
|
|
1297
|
+
it('uses admin-stored secret when available', async () => {
|
|
1298
|
+
const plugin = webhookPlugin()
|
|
1299
|
+
const ctx = makeCtx({ signingSecret: 'stored-secret' })
|
|
1300
|
+
await plugin.hooks?.['content.published']?.({ type: 'content.published', payload: {} as never }, ctx)
|
|
1301
|
+
// assert that the request was signed with 'stored-secret'
|
|
1302
|
+
})
|
|
1303
|
+
|
|
1304
|
+
it('falls back to constructor secret when admin has not saved one', async () => {
|
|
1305
|
+
const plugin = webhookPlugin({ signingSecret: 'fallback-secret' })
|
|
1306
|
+
const ctx = makeCtx({}) // no stored secret
|
|
1307
|
+
await plugin.hooks?.['content.published']?.({ type: 'content.published', payload: {} as never }, ctx)
|
|
1308
|
+
// assert that the request was signed with 'fallback-secret'
|
|
1309
|
+
})
|
|
1310
|
+
})
|
|
1311
|
+
```
|
|
1056
1312
|
|
|
1057
1313
|
---
|
|
1058
1314
|
|
|
@@ -25,21 +25,21 @@
|
|
|
25
25
|
"@tiptap/pm": "^3.23.6",
|
|
26
26
|
"@tiptap/react": "^3.23.6",
|
|
27
27
|
"@tiptap/starter-kit": "^3.23.6",
|
|
28
|
-
"@ampless/plugin-analytics-ga4": "^0.2.0-alpha.
|
|
29
|
-
"@ampless/plugin-cookie-consent": "^0.1.0-alpha.
|
|
30
|
-
"@ampless/plugin-gtm": "^0.2.0-alpha.
|
|
31
|
-
"@ampless/plugin-og-image": "^0.2.0-alpha.
|
|
32
|
-
"@ampless/plugin-plausible": "^0.2.0-alpha.
|
|
33
|
-
"@ampless/plugin-reading-time": "^0.1.0-alpha.
|
|
34
|
-
"@ampless/plugin-rss": "^0.2.0-alpha.
|
|
35
|
-
"@ampless/plugin-schema-jsonld": "^0.1.1-alpha.
|
|
36
|
-
"@ampless/plugin-seo": "^0.2.0-alpha.
|
|
37
|
-
"@ampless/plugin-webhook": "^0.2.0-alpha.
|
|
38
|
-
"@ampless/admin": "^1.0.0-alpha.
|
|
39
|
-
"@ampless/backend": "^1.0.0-alpha.
|
|
40
|
-
"@ampless/runtime": "^1.0.0-alpha.
|
|
28
|
+
"@ampless/plugin-analytics-ga4": "^0.2.0-alpha.18",
|
|
29
|
+
"@ampless/plugin-cookie-consent": "^0.1.0-alpha.9",
|
|
30
|
+
"@ampless/plugin-gtm": "^0.2.0-alpha.17",
|
|
31
|
+
"@ampless/plugin-og-image": "^0.2.0-alpha.34",
|
|
32
|
+
"@ampless/plugin-plausible": "^0.2.0-alpha.17",
|
|
33
|
+
"@ampless/plugin-reading-time": "^0.1.0-alpha.7",
|
|
34
|
+
"@ampless/plugin-rss": "^0.2.0-alpha.34",
|
|
35
|
+
"@ampless/plugin-schema-jsonld": "^0.1.1-alpha.13",
|
|
36
|
+
"@ampless/plugin-seo": "^0.2.0-alpha.34",
|
|
37
|
+
"@ampless/plugin-webhook": "^0.2.0-alpha.35",
|
|
38
|
+
"@ampless/admin": "^1.0.0-alpha.67",
|
|
39
|
+
"@ampless/backend": "^1.0.0-alpha.55",
|
|
40
|
+
"@ampless/runtime": "^1.0.0-alpha.43",
|
|
41
41
|
"@digital-go-jp/tailwind-theme-plugin": "^0.3.4",
|
|
42
|
-
"ampless": "^1.0.0-alpha.
|
|
42
|
+
"ampless": "^1.0.0-alpha.34",
|
|
43
43
|
"aws-amplify": "^6.17.0",
|
|
44
44
|
"class-variance-authority": "^0.7.1",
|
|
45
45
|
"clsx": "^2.1.1",
|