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 +64 -4
- package/dist/index.js +12 -0
- package/docs/plugin-author-guide.ja.md +117 -1
- package/docs/plugin-author-guide.md +188 -2
- package/package.json +1 -1
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' | '
|
|
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`
|
|
704
|
-
*
|
|
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
|
-
- **
|
|
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
|