ampless 1.0.0-alpha.27 → 1.0.0-alpha.29

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/index.d.ts CHANGED
@@ -292,6 +292,9 @@ type TrustLevel = 'untrusted' | 'trusted' | 'privileged';
292
292
  * scoped to JSON-LD `<script type="application/ld+json">`. Themes
293
293
  * render the descriptors by calling `ampless.publicBodyForPost(post)`
294
294
  * in their post template.
295
+ * - `secretSettings`: admin-managed secret settings stored in the isolated
296
+ * `PluginSecret` DynamoDB model. Requires `trust_level: 'trusted'`.
297
+ * Trusted hooks access secrets via `ctx.secret<T>(key)`.
295
298
  *
296
299
  * Reserved capabilities are accepted by the type so that plugins can
297
300
  * declare future intent, but the runtime does nothing with them yet —
@@ -299,7 +302,7 @@ type TrustLevel = 'untrusted' | 'trusted' | 'privileged';
299
302
  * capability today is harmless, but the runtime won't expose any new
300
303
  * surface for it until the matching phase ships.
301
304
  */
302
- type PluginCapability = 'publicHead' | 'publicBody' | 'metadata' | 'eventHooks' | 'adminSettings' | 'writePublicAsset' | 'schema' | 'contentFields' | 'adminPage' | 'serverRoute' | 'secretSettings' | 'network' | 'scheduler' | 'storageWrite' | 'privilegedSystem';
305
+ type PluginCapability = 'publicHead' | 'publicBody' | 'metadata' | 'eventHooks' | 'adminSettings' | 'writePublicAsset' | 'schema' | 'publicHtmlForPost' | 'secretSettings' | 'contentFields' | 'adminPage' | 'serverRoute' | 'network' | 'scheduler' | 'storageWrite' | 'privilegedSystem';
303
306
  /**
304
307
  * Loading strategy for `script` / `inlineScript` descriptors.
305
308
  *
@@ -449,6 +452,29 @@ type PublicPostBodyDescriptor = Extract<PublicHeadDescriptor, {
449
452
  * whose scriptType is not 'application/ld+json'. */
450
453
  scriptType: 'application/ld+json';
451
454
  };
455
+ /**
456
+ * Slot positions for `publicHtmlForPost` descriptors. v1 ships two
457
+ * fixed slots; additional slots (beforeTitle / sidebar / etc) are
458
+ * deferred until dogfood reveals the need.
459
+ */
460
+ type PublicPostHtmlPosition = 'beforeContent' | 'afterContent';
461
+ /**
462
+ * Per-post visible HTML descriptor returned by `publicHtmlForPost`.
463
+ * The runtime sanitizes `body` with `sanitize-html` under a strict
464
+ * allowlist before rendering; see the plugin-author guide for how
465
+ * trusted plugins compose with the public surface.
466
+ *
467
+ * `id` is a plugin-local short identifier (e.g. `'display'`). The
468
+ * runtime resolves it to `${instanceId ?? name}:${id}` when building
469
+ * the React wrapper key — plugin authors do not embed their own
470
+ * namespace in `id`.
471
+ */
472
+ interface PublicPostHtmlDescriptor {
473
+ type: 'html';
474
+ id: string;
475
+ body: string;
476
+ position: PublicPostHtmlPosition;
477
+ }
452
478
  /**
453
479
  * Metadata-like object that maps cleanly onto Next.js `Metadata`. We keep
454
480
  * the shape framework-agnostic so the core type doesn't depend on Next.js.
@@ -498,6 +524,35 @@ interface PluginRuntimeContext {
498
524
  */
499
525
  writePublicAsset(key: string, body: string | Uint8Array, contentType: string): Promise<string>;
500
526
  }
527
+ /**
528
+ * Extended runtime context for **trusted** hook handlers. Adds
529
+ * `secret<T>(key)` — an async accessor that reads from the isolated
530
+ * `PluginSecret` DynamoDB model (which admin / editor groups cannot
531
+ * query). The result is per-invocation cached to avoid redundant DDB
532
+ * calls when the same key is read multiple times inside one hook batch.
533
+ *
534
+ * Cache key is `${instanceId ?? name}:${fieldKey}` to prevent
535
+ * cross-plugin collisions when two plugin instances declare the same
536
+ * `key` (e.g. both have a `'signingSecret'` field).
537
+ *
538
+ * Only available in the trusted processor (`processor-trusted.ts`).
539
+ * The untrusted processor never constructs a `TrustedPluginRuntimeContext`
540
+ * — untrusted hook handlers receive plain `PluginRuntimeContext`, which
541
+ * does not expose `secret`.
542
+ */
543
+ interface TrustedPluginRuntimeContext extends PluginRuntimeContext {
544
+ /**
545
+ * Read a secret value stored under the plugin's namespace in the
546
+ * `PluginSecret` table. Returns `undefined` when no value has been
547
+ * saved yet. The generic `T` is a convenience cast (same pattern as
548
+ * `ctx.setting<T>()`) — values are always stored as strings, so `T`
549
+ * defaults to `string`.
550
+ *
551
+ * Per-invocation cached: calling `ctx.secret('key')` twice within
552
+ * the same SQS batch hit costs one DDB round-trip.
553
+ */
554
+ secret<T = string>(key: string): Promise<T | undefined>;
555
+ }
501
556
  /**
502
557
  * A font registered with a plugin's OG image renderer. Satori (the engine
503
558
  * inside Next.js `ImageResponse`) requires at least one font. We accept
@@ -675,13 +730,41 @@ interface PluginPackageManifest {
675
730
  /** Optional docs / repo URL. */
676
731
  homepage?: string;
677
732
  }
733
+ /**
734
+ * Secret field types for `settings.secret`. Restricted to `text` /
735
+ * `textarea` — the only types useful for opaque string secrets
736
+ * (API keys, signing secrets, SMTP passwords). More complex types
737
+ * (number, boolean, select, repeatable) are excluded: structured
738
+ * secrets are out of scope for v1.
739
+ *
740
+ * The `default` property is intentionally stripped via `Omit`. If it
741
+ * were allowed, the default value would propagate into the admin form
742
+ * props (visible in the browser), static manifests cross-checked by
743
+ * the runtime, and JS bundles — multiple leak paths for a value that
744
+ * must stay server-side. Plugin authors that have a constructor-time
745
+ * fallback value should keep it as a **closure-private variable** that
746
+ * is never exposed in the manifest (see the plugin author guide for
747
+ * the "closure-private fallback" pattern).
748
+ */
749
+ type PluginSecretField = Omit<PluginTextField, 'default'> | Omit<PluginTextareaField, 'default'>;
678
750
  /**
679
751
  * Per-plugin settings declaration. Phase 2 implements `public`;
680
- * `secret` is reserved for Phase 6a (admin-only storage; never reaches
681
- * the public runtime).
752
+ * Phase 6a adds `secret` (admin-only storage; never reaches the public
753
+ * runtime or S3 mirror).
682
754
  */
683
755
  interface PluginSettingsManifest {
684
756
  public?: readonly PluginSettingField[];
757
+ /**
758
+ * Admin-managed secret settings. Values are stored in the isolated
759
+ * `PluginSecret` DynamoDB model, which has no `read` authorization
760
+ * for admin / editor groups — only trusted Lambda IAM can read them.
761
+ * Secrets never reach the public runtime or the S3 site-settings
762
+ * mirror. Requires `trust_level: 'trusted'` and the
763
+ * `'secretSettings'` capability. Declaring this with an untrusted
764
+ * plugin throws at `definePlugin()` time; declaring it without the
765
+ * capability warns.
766
+ */
767
+ secret?: readonly PluginSecretField[];
685
768
  }
686
769
  interface AmplessPlugin {
687
770
  name: string;
@@ -790,6 +873,28 @@ interface AmplessPlugin {
790
873
  * Plugins implementing this should declare the `schema` capability.
791
874
  */
792
875
  publicBodyForPost?(post: Post, ctx: PluginPublicRenderContext): readonly PublicPostBodyDescriptor[];
876
+ /**
877
+ * Per-post visible HTML descriptors (Phase 6d). Themes render the
878
+ * result by calling `ampless.publicHtmlForPost(post)` in their post
879
+ * template; descriptors emit inside `<body>` at the `beforeContent`
880
+ * or `afterContent` slot relative to the post prose. Primary use
881
+ * cases: reading-time badge, breadcrumb, share links, micro-format
882
+ * annotations.
883
+ *
884
+ * Each descriptor's `body` is sanitized by the runtime under a
885
+ * strict `sanitize-html` allowlist before rendering — plugin authors
886
+ * may NOT call `dangerouslySetInnerHTML` themselves. The runtime
887
+ * wraps each surviving entry in a keyed `<div>` with the sanitized
888
+ * HTML.
889
+ *
890
+ * Plugin-local `id` (e.g. `'display'`) is namespace-resolved to
891
+ * `${instanceId ?? name}:${id}` by the runtime. Plugin authors do
892
+ * not embed their own namespace in `id`.
893
+ *
894
+ * Plugins implementing this should declare the `'publicHtmlForPost'`
895
+ * capability.
896
+ */
897
+ publicHtmlForPost?(post: Post, ctx: PluginPublicRenderContext): readonly PublicPostHtmlDescriptor[];
793
898
  /**
794
899
  * Dynamic OG image renderer. The dispatcher route (e.g.
795
900
  * `app/og/[slug]/route.ts`) reads this and feeds the element into
@@ -1337,4 +1442,4 @@ declare function pickDefaultEntrypoint(files: readonly {
1337
1442
 
1338
1443
  declare const VERSION = "0.0.1";
1339
1444
 
1340
- export { type AmplessEvent, type AmplessPlugin, type AuthContext, type BundleExtractResult, type CacheConfig, type CacheStrategy, type Config, type ContentEventPayload, type ContentEventType, type ContentFormat, type ContentTypeDefinition, type CreatePostInput, DEFAULT_ENTRYPOINT, type DateFormat, type EventPayloadOf, type EventType, type ExtractedFile, type FieldDefinition, type FieldType, type ImageDisplay, type KvItem, type KvStore, type LinkListItem, type ListOptions, type LocalizedString, MAX_BUNDLE_BYTES, type Media, type MediaEventPayload, type MediaEventType, type MediaMetadata, type MediaProcessingDefaults, type OgImageConfig, type OgImageFont, type OgImageRenderContext, PLUGIN_KEY_PATTERN, type Page, type PluginBooleanField, type PluginCapability, type PluginCodeField, type PluginEventHandler, type PluginJsonField, type PluginMetadata, type PluginNumberField, type PluginPackageManifest, type PluginPublicRenderContext, type PluginRepeatableField, type PluginRepeatableSubField, type PluginRuntimeContext, type PluginSelectField, type PluginSettingField, type PluginSettingsManifest, type PluginTextField, type PluginTextareaField, type PluginUrlField, type Post, type PostIndexEventPayload, type PostIndexEventType, type PostMetadata, type PostStatus, type PostsProvider, type PublicBodyDescriptor, type PublicHeadDescriptor, type PublicPostBodyDescriptor, type Role, SITE_CONFIG_PK, type ScriptStrategy, type SiteSettingsEventPayload, type SiteSettingsEventType, type StaticPostBody, type StaticPostFileMeta, type StreamEventName, TEXT_EXTENSIONS, type ThemeColorField, type ThemeField, type ThemeFieldType, type ThemeFontFamilyField, type ThemeImageField, type ThemeLengthField, type ThemeLinkListField, type ThemeManifest, type ThemeModule, type ThemeRouteContext, type ThemeSelectField, type ThemeTextField, type TrustLevel, VERSION, type ValidationIssue, bundlePrefix, createPost, decodeAwsJson, defineConfig, definePlugin, defineSchema, defineTheme, defineThemeModule, deletePost, deleteSiteSetting, detectContentEvents, encodeAwsJson, escapeXml, extractFirstImageUrl, findAbsolutePathRefs, flattenSettings, formatColorPair, formatDate, formatPublicAssetUrl, getKvStore, getPost, getPostById, getSiteSetting, hasKvStore, hasPostsProvider, isTagListUrl, isValidPluginKey, listPosts, listSiteSettings, mimeTypeFor, parseColorPair, parseLinkList, pickDefaultEntrypoint, resolveLocalized, resolvePluginSettings, resolveThemeValues, setKvStore, setPostsProvider, setSiteSetting, stringifyLinkList, stripCommonPrefix, themeSettingKey, unflattenSettings, updatePost, validateBundle, validateBundlePath, validatePluginSettingValue, validatePublicAssetKey, validateThemeValue };
1445
+ export { type AmplessEvent, type AmplessPlugin, type AuthContext, type BundleExtractResult, type CacheConfig, type CacheStrategy, type Config, type ContentEventPayload, type ContentEventType, type ContentFormat, type ContentTypeDefinition, type CreatePostInput, DEFAULT_ENTRYPOINT, type DateFormat, type EventPayloadOf, type EventType, type ExtractedFile, type FieldDefinition, type FieldType, type ImageDisplay, type KvItem, type KvStore, type LinkListItem, type ListOptions, type LocalizedString, MAX_BUNDLE_BYTES, type Media, type MediaEventPayload, type MediaEventType, type MediaMetadata, type MediaProcessingDefaults, type OgImageConfig, type OgImageFont, type OgImageRenderContext, PLUGIN_KEY_PATTERN, type Page, type PluginBooleanField, type PluginCapability, type PluginCodeField, type PluginEventHandler, type PluginJsonField, type PluginMetadata, type PluginNumberField, type PluginPackageManifest, type PluginPublicRenderContext, type PluginRepeatableField, type PluginRepeatableSubField, type PluginRuntimeContext, type PluginSecretField, type PluginSelectField, type PluginSettingField, type PluginSettingsManifest, type PluginTextField, type PluginTextareaField, type PluginUrlField, type Post, type PostIndexEventPayload, type PostIndexEventType, type PostMetadata, type PostStatus, type PostsProvider, type PublicBodyDescriptor, type PublicHeadDescriptor, type PublicPostBodyDescriptor, type PublicPostHtmlDescriptor, type PublicPostHtmlPosition, type Role, SITE_CONFIG_PK, type ScriptStrategy, type SiteSettingsEventPayload, type SiteSettingsEventType, type StaticPostBody, type StaticPostFileMeta, type StreamEventName, TEXT_EXTENSIONS, type ThemeColorField, type ThemeField, type ThemeFieldType, type ThemeFontFamilyField, type ThemeImageField, type ThemeLengthField, type ThemeLinkListField, type ThemeManifest, type ThemeModule, type ThemeRouteContext, type ThemeSelectField, type ThemeTextField, type TrustLevel, type TrustedPluginRuntimeContext, VERSION, type ValidationIssue, bundlePrefix, createPost, decodeAwsJson, defineConfig, definePlugin, defineSchema, defineTheme, defineThemeModule, deletePost, deleteSiteSetting, detectContentEvents, encodeAwsJson, escapeXml, extractFirstImageUrl, findAbsolutePathRefs, flattenSettings, formatColorPair, formatDate, formatPublicAssetUrl, getKvStore, getPost, getPostById, getSiteSetting, hasKvStore, hasPostsProvider, isTagListUrl, isValidPluginKey, listPosts, listSiteSettings, mimeTypeFor, parseColorPair, parseLinkList, pickDefaultEntrypoint, resolveLocalized, resolvePluginSettings, resolveThemeValues, setKvStore, setPostsProvider, setSiteSetting, stringifyLinkList, stripCommonPrefix, themeSettingKey, unflattenSettings, updatePost, validateBundle, validateBundlePath, validatePluginSettingValue, validatePublicAssetKey, validateThemeValue };
package/dist/index.js CHANGED
@@ -212,6 +212,18 @@ function formatPublicAssetUrl(bucket, region, key) {
212
212
 
213
213
  // src/plugin.ts
214
214
  function definePlugin(p) {
215
+ if (p.settings?.secret && p.settings.secret.length > 0) {
216
+ if (p.trust_level !== "trusted") {
217
+ throw new Error(
218
+ `[ampless] Plugin "${p.name}": settings.secret requires trust_level "trusted" but got "${p.trust_level}". Secret fields are only accessible from the trusted Lambda \u2014 the untrusted and privileged Lambdas have no IAM read access to the PluginSecret table. Either change trust_level to "trusted" or remove settings.secret.`
219
+ );
220
+ }
221
+ if (p.capabilities && !p.capabilities.includes("secretSettings")) {
222
+ console.warn(
223
+ `[ampless] Plugin "${p.name}": settings.secret is declared but "secretSettings" is not in capabilities. Add "secretSettings" to capabilities so admin UI and future capability gates can see the declaration.`
224
+ );
225
+ }
226
+ }
215
227
  return p;
216
228
  }
217
229
 
@@ -28,6 +28,7 @@ ampless はテーマとプラグインの両方を提供します。用途に合
28
28
  | 信頼できる副作用(S3 書き込み・外部 API 送信) | | ✓ (`writePublicAsset` + `trusted`) |
29
29
  | テーマに依存しない `<head>` / `<body>` 注入(アナリティクス・同意バナー) | | ✓ (`publicHead` / `publicBodyEnd`) |
30
30
  | 投稿単位の機械可読メタデータ(JSON-LD 等) | | ✓ (`schema` via `publicBodyForPost`) |
31
+ | 投稿本文の周囲の可視 HTML(reading-time、breadcrumb、share など) | | ✓ (`publicHtmlForPost`) |
31
32
  | 複数の ampless サイトで共有したいコード | | ✓ (npm パッケージとして公開) |
32
33
 
33
34
  判断の目安:
@@ -67,6 +68,7 @@ ampless プラグインは `AmplessPlugin` オブジェクトを返す TypeScrip
67
68
  | `publicHead(ctx)` | root layout の `<head>` | 同期 (async layout から呼ばれる) | 1 |
68
69
  | `publicBodyEnd(ctx)` | root layout の `<body>` 末尾 | 同期 | 1 |
69
70
  | `publicBodyForPost(post, ctx)` | テーマの post ページテンプレート(投稿単位) | 同期 | 4 |
71
+ | `publicHtmlForPost(post, ctx)` | テーマの post ページテンプレート(投稿単位、可視 HTML) | 同期 | 6d |
70
72
  | `ogImage` | `/og/[slug]` ルート | リクエスト時、公開 Lambda 内 | 既存 |
71
73
  | `hooks` | trust_level に応じた processor Lambda | 非同期、SQS イベントで起動 | 既存 |
72
74
  | `settings.public` | `/admin/plugins` フォーム | 宣言的なマニフェスト | 2 |
@@ -86,7 +88,7 @@ ampless プラグインは `AmplessPlugin` オブジェクトを返す TypeScrip
86
88
  Lambda (`hooks`) でやる
87
89
  - **admin ルート / server ルート / コンテンツフィールドの追加** —
88
90
  Phase 6b 予約
89
- - **secret の読み書き**。`secretSettings` capability Phase 6a 予約。
91
+ - **Admin routes / server routes / content fields.** Phase 6b 予約。
90
92
  `settings.public` に credential を置かないこと
91
93
 
92
94
  ---
@@ -181,6 +183,7 @@ interface AmplessPlugin {
181
183
  publicHead?(ctx): readonly PublicHeadDescriptor[]
182
184
  publicBodyEnd?(ctx): readonly PublicBodyDescriptor[]
183
185
  publicBodyForPost?(post: Post, ctx): readonly PublicPostBodyDescriptor[]
186
+ publicHtmlForPost?(post: Post, ctx): readonly PublicPostHtmlDescriptor[]
184
187
  ogImage?: OgImageConfig
185
188
  settings?: { public?: readonly PluginSettingField[] }
186
189
  }
@@ -309,6 +312,7 @@ SSR がデッドラインなしでブロックします。ネットワーク呼
309
312
  | `publicHead(ctx)` | `PublicHeadDescriptor[]` | 解析ローダー、フォント、jsonld、hreflang |
310
313
  | `publicBodyEnd(ctx)` | `PublicBodyDescriptor[]` | GTM no-script フレーム、チャットウィジェット、末尾スニペット |
311
314
  | `publicBodyForPost(post, ctx)` | `PublicPostBodyDescriptor[]` | 投稿単位の body 注入 — JSON-LD 構造化データ。テーマの post ページテンプレートが render する |
315
+ | `publicHtmlForPost(post, ctx)` | `PublicPostHtmlDescriptor[]` | 投稿単位の可視 HTML を `beforeContent` / `afterContent` に注入 — reading-time バッジ、breadcrumb、share リンク等。body は runtime が `sanitize-html` の厳格 allowlist で sanitize |
312
316
 
313
317
  `ctx` オブジェクトの中身:
314
318
 
@@ -402,6 +406,21 @@ TypeScript としてサイトと同一の Node プロセスで動きます。des
402
406
 
403
407
  `meta` / `link` を除いている理由:投稿単位のメタデータは Next.js `generateMetadata()` 経由の `metadata()` サーフェスが担い、フレームワークの deduplication・streaming と統合されている。`publicBodyForPost` は `generateMetadata` が生成できない構造化データ(`<script type="application/ld+json">`)のためだけに存在する。
404
408
 
409
+ ### `PublicPostHtmlDescriptor`(Phase 6d)
410
+
411
+ `publicHtmlForPost` は `PublicPostHtmlDescriptor[]` を返します:
412
+
413
+ ```ts
414
+ {
415
+ type: 'html',
416
+ id: 'display', // plugin-local 短識別子(≤ 64 文字、制御文字不可)
417
+ position: 'beforeContent' | 'afterContent',
418
+ body: '<p class="reading-time">約 3 分で読めます</p>',
419
+ }
420
+ ```
421
+
422
+ runtime は `body` を `sanitize-html` の厳格 allowlist で sanitize し(詳しい allowlist と drop 対象は上の `publicHtmlForPost` 例を参照)、結果を `<div data-ampless-plugin="${namespace}" data-ampless-position="${position}">` で wrap します。テーマは `pages/post.tsx` で `{html.beforeContent}` / `{html.afterContent}` を embed するだけで、plugin の出力に対して `dangerouslySetInnerHTML` を書きません。
423
+
405
424
  ### JSON-LD 自動 escape
406
425
 
407
426
  `scriptType === 'application/ld+json'` のとき、runtime は描画前に **`body` 文字列を自動 escape** する — `<` → `<`、`>` → `>`、`&` → `&`、U+2028 → ` `、U+2029 → ` `。この処理は `inlineScript` を受け付ける 3 つのサーフェス(`publicHead` / `publicBodyEnd` / `publicBodyForPost`)すべてで行われる。プラグイン作者は生の JSON 文字列を返せばよく、自前で escape しなくてよい。
@@ -470,6 +489,60 @@ export default function schemaJsonldPlugin() {
470
489
 
471
490
  テーマの `pages/post.tsx` が `ampless.publicBodyForPost(post)` を呼び、返された descriptor を描画します。runtime は自動 escape した body を持つ `<script type="application/ld+json">` 要素をページに挿入します。
472
491
 
492
+ ### `publicHtmlForPost` 例(Phase 6d)
493
+
494
+ **可視 HTML** を post の周囲に出したいとき(reading-time バッジ、breadcrumb、share リンク、micro-format 注釈など)は `publicHtmlForPost` を使います。runtime が body を sanitize したうえで `beforeContent` / `afterContent` スロットに embed するので、テーマ側で `dangerouslySetInnerHTML` を書く必要はありません。
495
+
496
+ ```typescript
497
+ import { definePlugin } from 'ampless'
498
+
499
+ export default function readingTimePlugin() {
500
+ return definePlugin({
501
+ name: 'reading-time',
502
+ apiVersion: 1,
503
+ trust_level: 'untrusted',
504
+ capabilities: ['publicHtmlForPost'],
505
+ publicHtmlForPost(post, _ctx) {
506
+ const words = countWords(post)
507
+ const minutes = Math.max(1, Math.round(words / 200))
508
+ return [{
509
+ type: 'html',
510
+ id: 'display',
511
+ position: 'beforeContent',
512
+ body: `<p class="reading-time" data-words="${words}" data-minutes="${minutes}">約 ${minutes} 分で読めます</p>`,
513
+ }]
514
+ },
515
+ })
516
+ }
517
+ ```
518
+
519
+ テーマの `pages/post.tsx` は `const html = await ampless.publicHtmlForPost(post)` を 1 回呼び、スロットを embed します:
520
+
521
+ ```tsx
522
+ {postBody} {/* publicBodyForPost — JSON-LD */}
523
+ {html.beforeContent} {/* publicHtmlForPost — beforeContent スロット */}
524
+ <div className="prose" dangerouslySetInnerHTML={{ __html: renderBody(post) }} />
525
+ {html.afterContent} {/* publicHtmlForPost — afterContent スロット */}
526
+ ```
527
+
528
+ **スロット位置**(v1): `'beforeContent'` / `'afterContent'` の 2 つ。
529
+
530
+ **Sanitizer(厳格、trust level に関わらず同一):**
531
+
532
+ - 許可タグ: `p` · `span` · `strong` · `em` · `a` · `code` · `br` · `ul` · `ol` · `li`
533
+ - 許可グローバル属性: `class` · `data-words` · `data-minutes` · `data-ampless-*`
534
+ - 許可 `<a>` 属性: `href` · `rel` · `target`。`target="_blank"` のとき sanitizer が `rel="noopener noreferrer"` を自動付与
535
+ - `href` で許可するスキーム: `http` / `https`。相対 URL (`./path` / `../path` / `/path` / `#anchor`) は素通り。`javascript:` / `data:` / `mailto:` / `tel:` / `vbscript:` は drop
536
+ - drop されるタグ・属性: `<img>` · `<iframe>` · `<video>` · `<audio>` · `<object>` · `<embed>` · `<form>` · `<style>` · インライン `style` · 全 event handler (`on*`)
537
+
538
+ allowlist 外のタグが必要になった場合は issue を立ててください。allowlist は設計上拡張するものであって、escape hatch ではありません。
539
+
540
+ **`id` は plugin-local。** 短い識別子(例: `'display'`)を使います。runtime が React `key` および wrapper `<div>` の `data-ampless-plugin` / `data-ampless-position` 属性を組むときに `${instanceId ?? name}:${id}` で resolve するので、plugin 作者が自前で namespace を埋め込む必要はありません。validator は `id` が空、制御文字を含む、64 文字超のいずれかなら descriptor を drop します。
541
+
542
+ **dedupe は position ごと。** 1 つの plugin instance が `beforeContent` と `afterContent` の両方に同じ `id` を返すのは OK(dedupe スコープが独立)。同じ position に同じ `id` を 2 回返すと最初の 1 件を残して 2 件目を warn 付きで drop します。
543
+
544
+ **複数 instance。** distinct な `instanceId` を持つ 2 つの `reading-time` instance(例: `reading-time-en` / `reading-time-jp`)は、同じ position に `id: 'display'` を返しても両方残ります(namespace が違うため)。
545
+
473
546
  ### クライアントサイドの DOM 操作はしない
474
547
 
475
548
  `publicHead` または `publicBodyEnd` から返したインラインスクリプトは、React がページを hydrate する前、HTML のパース中に実行されます。**React が管理するサブツリー内の見える DOM を操作してはいけません** — hydration が走ると React は仮想 DOM と合わないツリーを検出し、`Hydration failed because the server rendered HTML didn't match the client` エラーを投げてサブツリーをゼロから再生成します。挿入したノードは消えてしまいます。
@@ -488,7 +561,7 @@ React 19 はさらに、クライアントコンポーネントのレンダー
488
561
  - テーマが描画した要素のクラス / 属性 / テキストコンテンツの変更
489
562
  - クライアントサイドで `#post-body` のような要素を読み取って投稿単位の HTML を挿入する — 現在 `publicHead`-for-post に相当するサーフェスはなく、サーバーレンダリング済みのサブツリーをクライアントサイドで書き換えると hydration と競合します
490
563
 
491
- 投稿単位の見える出力には、`publicBodyForPost` 経由で JSON-LD を返す(§6 の `PublicPostBodyDescriptor` 参照)か、テーマ自身のテンプレートを使ってください。サーバーサイドでの投稿単位 HTML 注入はロードマップに載っています。
564
+ 投稿単位の見える出力には `publicHtmlForPost` を使ってください(Phase 6d — 上の例と §6 の `PublicPostHtmlDescriptor` 参照)。runtime が post 本文の周囲の固定スロットにサーバーサイド HTML を出すので、hydration と競合しません。
492
565
 
493
566
  ---
494
567
 
@@ -629,6 +702,122 @@ stored 値 (validated)
629
702
 
630
703
  ---
631
704
 
705
+ ## 9a. Secret settings: `ctx.secret<T>(key)` (Phase 6a)
706
+
707
+ Secret settings を使うと、trusted プラグインが認証情報 (Webhook 署名 secret・SMTP パスワード・外部 API トークン等) を admin UI 経由で保存・ローテーションできます。**公開サイトやブラウザ側コードに値が流れることはありません**。
708
+
709
+ ### なぜ `settings.public` と API が違うのか
710
+
711
+ `settings.public` の値は公開 runtime に流れる設計です。`public/site-settings.json` にミラーされ、`ctx.setting()` で sync render surface から読めます。analytics の measurementId などには適切ですが、Webhook 署名 secret には絶対に使えません。
712
+
713
+ `settings.secret` はストレージモデルが構造的に異なります:
714
+
715
+ - KvStore とは **別テーブル** の `PluginSecret` DynamoDB model に保存。
716
+ - admin/editor グループは **書き込み・削除のみ可**。AppSync が `getPluginSecret` / `listPluginSecrets` を生成しない(read 権限がない)。
717
+ - trusted-processor Lambda IAM role だけが DDB `GetItem` で読み取れる。
718
+ - S3 mirror 経路に絶対に流れない(mirror は KvStore のみを query する)。
719
+ - 公開 render surface (`publicHead` など) からは読めない。
720
+
721
+ ### 要件
722
+
723
+ `settings.secret` には 2 つの要件があります:
724
+
725
+ 1. `trust_level: 'trusted'` — untrusted Lambda には PluginSecret table への DDB read 権限がない。他の trust level で宣言すると `definePlugin()` 時に throw する。
726
+ 2. `'secretSettings'` を `capabilities` に含める — admin UI や将来の allow-list から capability を参照できるようにするため必須。省略すると console.warn(`'schema'` vs `publicBodyForPost` の不整合パターンと同じ)。
727
+
728
+ ### secret フィールドの宣言
729
+
730
+ ```ts
731
+ import { definePlugin } from 'ampless'
732
+
733
+ export default function webhookPlugin(opts?: { signingSecret?: string }) {
734
+ // constructor から渡された secret は closure-private な fallback として保持。
735
+ // manifest にも descriptor にも出さない。
736
+ const constructorSecret = opts?.signingSecret
737
+
738
+ return definePlugin({
739
+ name: 'webhook',
740
+ apiVersion: 1,
741
+ trust_level: 'trusted',
742
+ capabilities: ['eventHooks', 'secretSettings'],
743
+ settings: {
744
+ secret: [
745
+ {
746
+ type: 'text',
747
+ key: 'signingSecret',
748
+ label: { en: 'Webhook signing secret', ja: 'Webhook 署名 secret' },
749
+ maxLength: 256,
750
+ required: false,
751
+ // `default` は型レベルで除外されている。closure-private fallback を使うこと。
752
+ },
753
+ ],
754
+ },
755
+ hooks: {
756
+ async 'content.published'(event, ctx) {
757
+ // ctx.secret() は PluginSecret DDB table から読む。
758
+ // admin が未保存なら undefined を返す。
759
+ const storedSecret = await ctx.secret<string>('signingSecret')
760
+
761
+ // closure-private fallback: admin が未保存の場合 constructor 引数を使う。
762
+ // これで既存サイトとの後方互換を維持できる。
763
+ const secret = storedSecret ?? constructorSecret
764
+ if (!secret) return
765
+
766
+ // ... secret で署名して POST
767
+ },
768
+ },
769
+ })
770
+ }
771
+ ```
772
+
773
+ ### 重要: secret フィールドに `default` を書かない
774
+
775
+ `PluginSecretField` 型は `Omit<PluginTextField, 'default'> | Omit<PluginTextareaField, 'default'>` として定義されており、**`default` プロパティは型レベルで除去**されています。追加しようとすると TypeScript がエラーを出します。
776
+
777
+ 理由: `default` は admin UI のフォーム props (ブラウザに送出される)、静的 manifest の cross-check、JS bundle など複数の経路で漏洩します。認証情報に使えない設計です。
778
+
779
+ fallback 値がある場合は、プラグイン factory 関数の closure-private 変数として保持してください:
780
+
781
+ ```ts
782
+ // ✓ 正解 — closure-private、manifest に出さない
783
+ const constructorSecret = opts?.signingSecret
784
+
785
+ // ✗ 誤り — TypeScript エラー、さらに browser にも漏れる
786
+ settings: {
787
+ secret: [{
788
+ type: 'text',
789
+ key: 'signingSecret',
790
+ label: 'Secret',
791
+ default: opts?.signingSecret, // ← TS compile error
792
+ }],
793
+ }
794
+ ```
795
+
796
+ ### secret の読み出し: `ctx.secret<T>(key)`
797
+
798
+ `ctx.secret<T>(key)` は trusted hook handler 内でのみ利用できます (`processor-trusted.ts` が注入)。シグネチャ:
799
+
800
+ ```ts
801
+ ctx.secret<T = string>(key: string): Promise<T | undefined>
802
+ ```
803
+
804
+ - admin が未保存なら `undefined` を返す。
805
+ - `T` は convenience cast (ctx.setting と同じ)。値は常に string として保存される。
806
+ - 結果は per-invocation キャッシュされる。同 batch 内で同キーを 2 回呼んでも DDB 呼び出しは 1 回。
807
+ - cache key は namespace 化される: `${instanceId ?? name}:${fieldKey}`。異なる plugin instance が同名フィールドを持っても混線しない。
808
+
809
+ ### admin UI
810
+
811
+ `settings.secret` を宣言すると、admin plugin settings ページの public フィールドの下に **Secret settings** セクションが表示されます。各フィールド:
812
+
813
+ - **未保存**: 通常テキスト入力 + Save ボタン。
814
+ - **保存済み**: マスク表示 `••••••••` + Replace + Clear ボタン。値は絶対に取得・表示されない。
815
+ - **編集中**: Replace クリック後 — 新値入力 + Save + Cancel。
816
+
817
+ admin は再デプロイなしにいつでも secret をローテーションできます。保存後 ~5〜10 秒以内に次の trusted Lambda 実行から新値が使われます。
818
+
819
+ ---
820
+
632
821
  ## 10. ウォークスルー: GA4 を Phase 1 から Phase 2 に移行する
633
822
 
634
823
  Phase 1 の GA4 プラグインは measurement ID を constructor 引数で受けていました。Phase 2 では後方互換のためにその引数を残しつつ、値は `ctx.setting()` 経由で読みます。
@@ -35,6 +35,7 @@ your code where future-you (and other site authors) will look for it.
35
35
  | Trusted side effects (S3 writes, external API push) | | ✓ (`writePublicAsset` + `trusted`) |
36
36
  | Theme-independent `<head>` / `<body>` injection (analytics, consent) | | ✓ (`publicHead` / `publicBodyEnd`) |
37
37
  | Per-post machine-readable metadata (JSON-LD, etc.) | | ✓ (`schema` via `publicBodyForPost`) |
38
+ | Per-post visible HTML around the body (reading-time, breadcrumb, share) | | ✓ (`publicHtmlForPost`) |
38
39
  | Code you want to share across multiple ampless sites | | ✓ (publish as npm package) |
39
40
 
40
41
  Rule of thumb:
@@ -88,9 +89,11 @@ surfaces:
88
89
  | `publicHead(ctx)` | root layout `<head>` | sync (called from async layout) | 1 |
89
90
  | `publicBodyEnd(ctx)` | root layout end of `<body>` | sync | 1 |
90
91
  | `publicBodyForPost(post, ctx)` | theme post page template (per-post) | sync | 4 |
92
+ | `publicHtmlForPost(post, ctx)` | theme post page template (per-post, visible HTML) | sync | 6d |
91
93
  | `ogImage` | `/og/[slug]` route | request-time, in public Lambda | Existing |
92
94
  | `hooks` | trust_level-matched processor Lambda | async, on SQS event | Existing |
93
95
  | `settings.public` | `/admin/plugins` form | declarative manifest | 2 |
96
+ | `settings.secret` | `/admin/plugins` secret section | declarative manifest, trusted Lambda only | 6a |
94
97
 
95
98
  A few surfaces don't exist yet — they're reserved for later phases
96
99
  and aren't shaped by `definePlugin` today:
@@ -109,8 +112,6 @@ and aren't shaped by `definePlugin` today:
109
112
  work belongs there.
110
113
  - **Admin routes / server routes / content fields.** Reserved for
111
114
  Phase 6b.
112
- - **Secrets.** The `secretSettings` capability is reserved for
113
- Phase 6a; nothing in `settings.public` should be a credential.
114
115
 
115
116
  ---
116
117
 
@@ -210,6 +211,7 @@ interface AmplessPlugin {
210
211
  publicHead?(ctx): readonly PublicHeadDescriptor[]
211
212
  publicBodyEnd?(ctx): readonly PublicBodyDescriptor[]
212
213
  publicBodyForPost?(post: Post, ctx): readonly PublicPostBodyDescriptor[]
214
+ publicHtmlForPost?(post: Post, ctx): readonly PublicPostHtmlDescriptor[]
213
215
  ogImage?: OgImageConfig
214
216
  settings?: { public?: readonly PluginSettingField[] }
215
217
  }
@@ -380,6 +382,7 @@ elevated AWS access.
380
382
  | `publicHead(ctx)` | `PublicHeadDescriptor[]` | Analytics loader, fonts, jsonld, hreflang |
381
383
  | `publicBodyEnd(ctx)` | `PublicBodyDescriptor[]` | GTM no-script frame, chat widgets, tail snippets |
382
384
  | `publicBodyForPost(post, ctx)` | `PublicPostBodyDescriptor[]` | Per-post body injection — JSON-LD structured data; rendered by the theme's post page template |
385
+ | `publicHtmlForPost(post, ctx)` | `PublicPostHtmlDescriptor[]` | Per-post visible HTML at `beforeContent` / `afterContent` — reading-time badge, breadcrumb, share links. Bodies are sanitized by the runtime under a strict `sanitize-html` allowlist |
383
386
 
384
387
  The `ctx` object carries:
385
388
 
@@ -483,6 +486,27 @@ behaviour. `publicBodyForPost` exists solely for the structured data
483
486
  use-case (`<script type="application/ld+json">`) that `generateMetadata`
484
487
  cannot produce.
485
488
 
489
+ ### `PublicPostHtmlDescriptor` (Phase 6d)
490
+
491
+ `publicHtmlForPost` returns `PublicPostHtmlDescriptor[]`:
492
+
493
+ ```ts
494
+ {
495
+ type: 'html',
496
+ id: 'display', // plugin-local short identifier (≤ 64 chars, no control chars)
497
+ position: 'beforeContent' | 'afterContent',
498
+ body: '<p class="reading-time">~3 min read</p>',
499
+ }
500
+ ```
501
+
502
+ The runtime sanitizes `body` with `sanitize-html` under a strict
503
+ allowlist (see the `publicHtmlForPost` example above for the full
504
+ allowlist + dropped-tag list) and wraps the result in
505
+ `<div data-ampless-plugin="${namespace}" data-ampless-position="${position}">`.
506
+ Themes embed `{html.beforeContent}` / `{html.afterContent}` in
507
+ `pages/post.tsx`; they never call `dangerouslySetInnerHTML` on plugin
508
+ output themselves.
509
+
486
510
  ### JSON-LD auto-escape
487
511
 
488
512
  When `scriptType === 'application/ld+json'`, the runtime **automatically
@@ -574,6 +598,80 @@ and renders the returned descriptors. The runtime inserts a
574
598
  `<script type="application/ld+json">` element with the auto-escaped
575
599
  body into the page.
576
600
 
601
+ ### `publicHtmlForPost` example (Phase 6d)
602
+
603
+ Use `publicHtmlForPost` when you need to render **visible HTML**
604
+ around a post — reading-time badge, breadcrumb, share links,
605
+ micro-format annotations, etc. The runtime sanitizes the body before
606
+ rendering and embeds the result at the `beforeContent` or
607
+ `afterContent` slot, so themes never call `dangerouslySetInnerHTML`
608
+ on plugin output.
609
+
610
+ ```typescript
611
+ import { definePlugin } from 'ampless'
612
+
613
+ export default function readingTimePlugin() {
614
+ return definePlugin({
615
+ name: 'reading-time',
616
+ apiVersion: 1,
617
+ trust_level: 'untrusted',
618
+ capabilities: ['publicHtmlForPost'],
619
+ publicHtmlForPost(post, _ctx) {
620
+ const words = countWords(post)
621
+ const minutes = Math.max(1, Math.round(words / 200))
622
+ return [{
623
+ type: 'html',
624
+ id: 'display',
625
+ position: 'beforeContent',
626
+ body: `<p class="reading-time" data-words="${words}" data-minutes="${minutes}">~${minutes} min read</p>`,
627
+ }]
628
+ },
629
+ })
630
+ }
631
+ ```
632
+
633
+ The theme's `pages/post.tsx` calls
634
+ `const html = await ampless.publicHtmlForPost(post)` once and embeds
635
+ the slots:
636
+
637
+ ```tsx
638
+ {postBody} {/* publicBodyForPost — JSON-LD */}
639
+ {html.beforeContent} {/* publicHtmlForPost — beforeContent slot */}
640
+ <div className="prose" dangerouslySetInnerHTML={{ __html: renderBody(post) }} />
641
+ {html.afterContent} {/* publicHtmlForPost — afterContent slot */}
642
+ ```
643
+
644
+ **Slot positions** (v1): `'beforeContent'` and `'afterContent'`.
645
+
646
+ **Sanitizer (strict, identical across trust levels):**
647
+
648
+ - Allowed tags: `p` · `span` · `strong` · `em` · `a` · `code` · `br` · `ul` · `ol` · `li`
649
+ - Allowed global attributes: `class` · `data-words` · `data-minutes` · `data-ampless-*`
650
+ - Allowed `<a>` attributes: `href` · `rel` · `target`. `target="_blank"` triggers auto-injection of `rel="noopener noreferrer"`.
651
+ - Allowed URL schemes on `href`: `http` / `https`. Relative URLs (`./path`, `../path`, `/path`, `#anchor`) pass through. `javascript:` / `data:` / `mailto:` / `tel:` / `vbscript:` are dropped.
652
+ - Dropped tags / attributes: `<img>` · `<iframe>` · `<video>` · `<audio>` · `<object>` · `<embed>` · `<form>` · `<style>` · inline `style` · all event handlers (`on*`).
653
+
654
+ If you need a tag outside this list, open an issue — the allowlist
655
+ expands by design, not by escape hatch.
656
+
657
+ **`id` is plugin-local.** Use a short identifier (e.g. `'display'`).
658
+ The runtime resolves it to `${instanceId ?? name}:${id}` when building
659
+ the React `key` and the wrapper `<div>`'s `data-ampless-plugin` /
660
+ `data-ampless-position` attributes. Do not embed your own namespace
661
+ in `id`. The validator drops descriptors whose `id` is empty,
662
+ contains control characters, or exceeds 64 characters.
663
+
664
+ **Dedupe is per-position.** Returning the same `id` to both
665
+ `beforeContent` and `afterContent` from a single plugin instance is
666
+ fine — they live in independent dedupe scopes. Returning the same
667
+ `id` twice to the same position keeps only the first occurrence and
668
+ warns.
669
+
670
+ **Multiple instances.** Two `reading-time` plugin instances with
671
+ distinct `instanceId` (e.g. `reading-time-en` / `reading-time-jp`)
672
+ can both emit `id: 'display'` to the same position; the runtime
673
+ keeps both because the namespaces differ.
674
+
577
675
  ### Client-side DOM mutation: don't
578
676
 
579
677
  Inline scripts you return from `publicHead` or `publicBodyEnd` execute
@@ -612,10 +710,10 @@ something like `document.body.append(myNewElement)` may not even fire.
612
710
  any client-side rewrite of a server-rendered subtree races against
613
711
  hydration
614
712
 
615
- For visible per-post output, return JSON-LD via `publicBodyForPost`
616
- (see §6's `PublicPostBodyDescriptor`) or stick with the theme's own
617
- templates. A future ampless capability for server-side per-post HTML
618
- injection is on the roadmap.
713
+ For visible per-post output, use `publicHtmlForPost` (Phase 6d — see
714
+ the example above and §6's `PublicPostHtmlDescriptor`). The runtime
715
+ emits server-side HTML at fixed slots around the post body, so
716
+ nothing races against hydration.
619
717
 
620
718
  ---
621
719
 
@@ -807,6 +905,193 @@ plugin's own `instanceId`.
807
905
 
808
906
  ---
809
907
 
908
+ ## 9a. Secret settings: `ctx.secret<T>(key)` (Phase 6a)
909
+
910
+ Secret settings let trusted plugins store and rotate credentials
911
+ (webhook signing secrets, SMTP passwords, external API tokens)
912
+ through the admin UI **without exposing them to the public site or
913
+ browser-side code**.
914
+
915
+ ### Why a separate API from `settings.public`?
916
+
917
+ Values in `settings.public` are designed to flow to the public
918
+ runtime: they're mirrored to `public/site-settings.json` and read
919
+ by `ctx.setting()` inside sync render surfaces. That flow is
920
+ intentional for analytics measurement IDs, consent category names,
921
+ etc. — but completely wrong for a webhook signing secret.
922
+
923
+ `settings.secret` has a structurally different storage model:
924
+
925
+ - Stored in the `PluginSecret` DynamoDB model (separate from KvStore).
926
+ - Admin/editor groups can **write and delete** but have **no read
927
+ authorization** — the AppSync schema does not generate
928
+ `getPluginSecret` or `listPluginSecrets` queries for those groups.
929
+ - Only the trusted-processor Lambda IAM role can read, via DDB
930
+ `GetItem` directly.
931
+ - Never queried by the site-settings mirror path.
932
+ - Never passed to any public-render surface
933
+ (`publicHead`, `publicBodyEnd`, `publicBodyForPost`,
934
+ `publicHtmlForPost`) — those surfaces only see `ctx.setting()`.
935
+
936
+ ### Requirements
937
+
938
+ `settings.secret` requires:
939
+
940
+ 1. `trust_level: 'trusted'` — untrusted Lambdas have no DDB read
941
+ access to the PluginSecret table. `definePlugin()` throws if you
942
+ declare `settings.secret` with any other trust level.
943
+ 2. `'secretSettings'` in `capabilities` — required so admin UI and
944
+ future allow-lists can gate the capability. Omitting it produces
945
+ a console warning (same pattern as `'schema'` vs `publicBodyForPost`).
946
+
947
+ ### Declaring secret fields
948
+
949
+ ```ts
950
+ import { definePlugin } from 'ampless'
951
+
952
+ export default function webhookPlugin(opts?: { signingSecret?: string }) {
953
+ // Keep any constructor-provided secret as a closure-private fallback.
954
+ // It is NEVER exposed in the manifest or the stored descriptor.
955
+ const constructorSecret = opts?.signingSecret
956
+
957
+ return definePlugin({
958
+ name: 'webhook',
959
+ apiVersion: 1,
960
+ trust_level: 'trusted',
961
+ capabilities: ['eventHooks', 'secretSettings'],
962
+ settings: {
963
+ secret: [
964
+ {
965
+ type: 'text',
966
+ key: 'signingSecret',
967
+ label: { en: 'Webhook signing secret', ja: 'Webhook 署名 secret' },
968
+ maxLength: 256,
969
+ required: false,
970
+ // NO `default` — secret fields forbid it at the type level.
971
+ // Use a closure-private fallback instead (see below).
972
+ },
973
+ ],
974
+ },
975
+ hooks: {
976
+ async 'content.published'(event, ctx) {
977
+ // ctx.secret() reads from PluginSecret DDB table.
978
+ // Returns undefined when no value has been saved by admin yet.
979
+ const storedSecret = await ctx.secret<string>('signingSecret')
980
+
981
+ // Closure-private fallback: use the constructor argument when
982
+ // the admin has not saved a value yet. This preserves backward
983
+ // compatibility with sites that pass the secret at install time.
984
+ const secret = storedSecret ?? constructorSecret
985
+ if (!secret) return // no secret → skip signing
986
+
987
+ // ... use secret to sign and POST
988
+ },
989
+ },
990
+ })
991
+ }
992
+ ```
993
+
994
+ ### Important: no `default` on secret fields
995
+
996
+ The type `PluginSecretField` is defined as `Omit<PluginTextField,
997
+ 'default'> | Omit<PluginTextareaField, 'default'>` — the `default`
998
+ property is **removed at the type level** and TypeScript will error
999
+ if you try to add one.
1000
+
1001
+ This is intentional: `default` values propagate into admin UI form
1002
+ props (visible in the browser), static manifests cross-checked by
1003
+ the runtime, and JS bundles. For a credential, these are all leak
1004
+ paths.
1005
+
1006
+ If you have a fallback value (e.g. the constructor argument), keep
1007
+ it as a **closure-private variable** inside the plugin factory
1008
+ function — never in the manifest:
1009
+
1010
+ ```ts
1011
+ // ✓ correct — closure-private, never in the manifest
1012
+ const constructorSecret = opts?.signingSecret
1013
+
1014
+ // ✗ wrong — TypeScript error, would also leak to browser
1015
+ settings: {
1016
+ secret: [{
1017
+ type: 'text',
1018
+ key: 'signingSecret',
1019
+ label: 'Secret',
1020
+ default: opts?.signingSecret, // ← TS compile error
1021
+ }],
1022
+ }
1023
+ ```
1024
+
1025
+ ### Reading secrets: `ctx.secret<T>(key)`
1026
+
1027
+ `ctx.secret<T>(key)` is only available in trusted hook handlers
1028
+ (injected by `processor-trusted.ts`). The signature is:
1029
+
1030
+ ```ts
1031
+ ctx.secret<T = string>(key: string): Promise<T | undefined>
1032
+ ```
1033
+
1034
+ - Returns `undefined` when no value has been saved by admin yet.
1035
+ - The generic `T` is a convenience cast (same as `ctx.setting<T>()`).
1036
+ Values are always stored as strings; `T` defaults to `string`.
1037
+ - Results are per-invocation cached. Calling `ctx.secret('key')`
1038
+ twice in the same hook batch costs one DDB round-trip.
1039
+ - Cache keys are namespaced: `${instanceId ?? name}:${fieldKey}`.
1040
+ Two plugin instances both declaring `'signingSecret'` never get
1041
+ each other's values.
1042
+
1043
+ ### Admin UI
1044
+
1045
+ When `settings.secret` is declared, the admin plugin settings page
1046
+ renders a **Secret settings** section below the public fields.
1047
+ Each secret field shows:
1048
+
1049
+ - **Unset**: a plain text input + Save button.
1050
+ - **Stored**: a masked placeholder `••••••••` + Replace + Clear
1051
+ buttons. The actual value is never fetched or displayed.
1052
+ - **Editing**: after clicking Replace — new text input + Save + Cancel.
1053
+
1054
+ Admins can rotate a secret at any time without redeploying. The
1055
+ change takes effect on the next trusted-Lambda invocation (within
1056
+ ~5–10 seconds of saving).
1057
+
1058
+ ### Testing hooks that use `ctx.secret`
1059
+
1060
+ Mock `ctx.secret` alongside the other context methods:
1061
+
1062
+ ```ts
1063
+ import { describe, it, expect, vi } from 'vitest'
1064
+ import webhookPlugin from './index.js'
1065
+ import type { TrustedPluginRuntimeContext } from 'ampless'
1066
+
1067
+ function makeCtx(secrets: Record<string, string> = {}): TrustedPluginRuntimeContext {
1068
+ return {
1069
+ site: { name: 'Test', url: 'https://example.com' },
1070
+ listPublishedPosts: vi.fn().mockResolvedValue([]),
1071
+ writePublicAsset: vi.fn().mockResolvedValue(''),
1072
+ secret: vi.fn().mockImplementation(async (key: string) => secrets[key]),
1073
+ }
1074
+ }
1075
+
1076
+ describe('webhookPlugin signing', () => {
1077
+ it('uses admin-stored secret when available', async () => {
1078
+ const plugin = webhookPlugin()
1079
+ const ctx = makeCtx({ signingSecret: 'stored-secret' })
1080
+ await plugin.hooks?.['content.published']?.({ type: 'content.published', payload: {} as never }, ctx)
1081
+ // assert that the request was signed with 'stored-secret'
1082
+ })
1083
+
1084
+ it('falls back to constructor secret when admin has not saved one', async () => {
1085
+ const plugin = webhookPlugin({ signingSecret: 'fallback-secret' })
1086
+ const ctx = makeCtx({}) // no stored secret
1087
+ await plugin.hooks?.['content.published']?.({ type: 'content.published', payload: {} as never }, ctx)
1088
+ // assert that the request was signed with 'fallback-secret'
1089
+ })
1090
+ })
1091
+ ```
1092
+
1093
+ ---
1094
+
810
1095
  ## 10. Walk-through: migrating GA4 from Phase 1 to Phase 2
811
1096
 
812
1097
  The Phase 1 GA4 plugin took the measurement ID through a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ampless",
3
- "version": "1.0.0-alpha.27",
3
+ "version": "1.0.0-alpha.29",
4
4
  "description": "Serverless CMS for AWS Amplify",
5
5
  "license": "MIT",
6
6
  "type": "module",