ampless 1.0.0-alpha.28 → 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' | 'publicHtmlForPost' | '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
  *
@@ -521,6 +524,35 @@ interface PluginRuntimeContext {
521
524
  */
522
525
  writePublicAsset(key: string, body: string | Uint8Array, contentType: string): Promise<string>;
523
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
+ }
524
556
  /**
525
557
  * A font registered with a plugin's OG image renderer. Satori (the engine
526
558
  * inside Next.js `ImageResponse`) requires at least one font. We accept
@@ -698,13 +730,41 @@ interface PluginPackageManifest {
698
730
  /** Optional docs / repo URL. */
699
731
  homepage?: string;
700
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'>;
701
750
  /**
702
751
  * Per-plugin settings declaration. Phase 2 implements `public`;
703
- * `secret` is reserved for Phase 6a (admin-only storage; never reaches
704
- * the public runtime).
752
+ * Phase 6a adds `secret` (admin-only storage; never reaches the public
753
+ * runtime or S3 mirror).
705
754
  */
706
755
  interface PluginSettingsManifest {
707
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[];
708
768
  }
709
769
  interface AmplessPlugin {
710
770
  name: string;
@@ -1382,4 +1442,4 @@ declare function pickDefaultEntrypoint(files: readonly {
1382
1442
 
1383
1443
  declare const VERSION = "0.0.1";
1384
1444
 
1385
- 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 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, 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
 
@@ -88,7 +88,7 @@ ampless プラグインは `AmplessPlugin` オブジェクトを返す TypeScrip
88
88
  Lambda (`hooks`) でやる
89
89
  - **admin ルート / server ルート / コンテンツフィールドの追加** —
90
90
  Phase 6b 予約
91
- - **secret の読み書き**。`secretSettings` capability Phase 6a 予約。
91
+ - **Admin routes / server routes / content fields.** Phase 6b 予約。
92
92
  `settings.public` に credential を置かないこと
93
93
 
94
94
  ---
@@ -702,6 +702,122 @@ stored 値 (validated)
702
702
 
703
703
  ---
704
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
+
705
821
  ## 10. ウォークスルー: GA4 を Phase 1 から Phase 2 に移行する
706
822
 
707
823
  Phase 1 の GA4 プラグインは measurement ID を constructor 引数で受けていました。Phase 2 では後方互換のためにその引数を残しつつ、値は `ctx.setting()` 経由で読みます。
@@ -93,6 +93,7 @@ surfaces:
93
93
  | `ogImage` | `/og/[slug]` route | request-time, in public Lambda | Existing |
94
94
  | `hooks` | trust_level-matched processor Lambda | async, on SQS event | Existing |
95
95
  | `settings.public` | `/admin/plugins` form | declarative manifest | 2 |
96
+ | `settings.secret` | `/admin/plugins` secret section | declarative manifest, trusted Lambda only | 6a |
96
97
 
97
98
  A few surfaces don't exist yet — they're reserved for later phases
98
99
  and aren't shaped by `definePlugin` today:
@@ -111,8 +112,6 @@ and aren't shaped by `definePlugin` today:
111
112
  work belongs there.
112
113
  - **Admin routes / server routes / content fields.** Reserved for
113
114
  Phase 6b.
114
- - **Secrets.** The `secretSettings` capability is reserved for
115
- Phase 6a; nothing in `settings.public` should be a credential.
116
115
 
117
116
  ---
118
117
 
@@ -906,6 +905,193 @@ plugin's own `instanceId`.
906
905
 
907
906
  ---
908
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
+
909
1095
  ## 10. Walk-through: migrating GA4 from Phase 1 to Phase 2
910
1096
 
911
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.28",
3
+ "version": "1.0.0-alpha.29",
4
4
  "description": "Serverless CMS for AWS Amplify",
5
5
  "license": "MIT",
6
6
  "type": "module",