ampless 1.0.0-alpha.35 → 1.0.0-alpha.37

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
@@ -633,6 +633,28 @@ interface PluginRuntimeContext {
633
633
  */
634
634
  writePublicAsset(key: string, body: string | Uint8Array, contentType: string): Promise<string>;
635
635
  }
636
+ /**
637
+ * Context passed to the `uninstall` lifecycle hook (Phase 1
638
+ * reservation: runtime no-op — see `AmplessPlugin.uninstall`).
639
+ *
640
+ * Phase 1: structurally identical to `PluginRuntimeContext`. The
641
+ * dedicated type exists so future cleanup helpers
642
+ * (`deletePublicAsset`, `deletePluginSetting`, `deletePluginSecret`)
643
+ * can be added here without exposing them to regular event hooks,
644
+ * which take `PluginRuntimeContext` and should not be able to
645
+ * delete state from arbitrary plugin areas.
646
+ *
647
+ * Phase 1 scope is limited to: (a) reserving the type name, (b)
648
+ * reserving the hook signature `(ctx: PluginUninstallContext) =>
649
+ * Promise<void>`. Cleanup helper methods are NOT included in
650
+ * Phase 1 — plugin authors writing `await ctx.deletePublicAsset(...)`
651
+ * today would hit a TS error. The actual cleanup body lands when
652
+ * the future lifecycle-dispatch PR adds the helper methods to this
653
+ * type (additive, no breaking change for plugins that declared an
654
+ * empty `uninstall` body in advance).
655
+ */
656
+ interface PluginUninstallContext extends PluginRuntimeContext {
657
+ }
636
658
  /**
637
659
  * Extended runtime context for **trusted** hook handlers. Adds
638
660
  * `secret<T>(key)` — an async accessor that reads from the isolated
@@ -874,6 +896,80 @@ interface PluginSettingsManifest {
874
896
  * capability warns.
875
897
  */
876
898
  secret?: readonly PluginSecretField[];
899
+ /**
900
+ * Optional manifest shape version (Phase 1 reservation: runtime
901
+ * no-op).
902
+ *
903
+ * Plugin authors bump this integer when they change the shape of
904
+ * `public` or `secret` field arrays in a way that today's
905
+ * write/read paths cannot transparently absorb — i.e. when a field
906
+ * is renamed, when a field's `type` changes incompatibly, or when
907
+ * a field's semantic meaning shifts (same key + same type but the
908
+ * value should be re-interpreted).
909
+ *
910
+ * **Phase 1 scope**: the field is type-only. The runtime does NOT
911
+ * read it today, does NOT persist it next to stored values, and
912
+ * does NOT trigger any migration. Today's behaviour around shape
913
+ * changes continues to apply, but it differs between `public` and
914
+ * `secret` because the two travel through entirely different
915
+ * write/read paths:
916
+ *
917
+ * - `settings.public` goes through `resolvePluginSettings`
918
+ * (`packages/ampless/src/plugin-settings.ts`), which iterates
919
+ * `manifest.public` and falls back to `field.default` per
920
+ * field. So for public fields, additions resolve via `default`,
921
+ * deletions become orphan KvStore rows that the resolver
922
+ * silently skips, and incompatible type changes fall through
923
+ * to `default` when the stored value fails validation.
924
+ * - `settings.secret` is never read by `resolvePluginSettings`.
925
+ * The admin UI writes individual values through the
926
+ * `setPluginSecret` AppSync mutation; trusted hooks read them
927
+ * individually by key via `ctx.secret<T>(key)` which goes
928
+ * directly to the `PluginSecret` DynamoDB table. There is no
929
+ * `manifest.default` fallback for secrets (the type forbids
930
+ * `default` on secret fields). Additions show up in the admin
931
+ * UI when the manifest changes; removals leave the stored
932
+ * ciphertext row orphaned (no resolver runs over it); renames
933
+ * and type changes mean the old key's stored row is never
934
+ * read again until an operator deletes it manually.
935
+ *
936
+ * None of these paths produce a signal to the plugin author.
937
+ *
938
+ * **Future migration PR**: may persist the active manifest
939
+ * version somewhere alongside stored values and may compare it
940
+ * to `manifest.version` at resolve time to detect mismatch. The
941
+ * exact storage location, comparison timing, and mismatch
942
+ * response are all design territory for that future PR — this
943
+ * reservation fixes the field name and type on the manifest,
944
+ * nothing more.
945
+ *
946
+ * Important scope distinction: this reservation covers the
947
+ * `version` *field name and type* on `PluginSettingsManifest`
948
+ * only. The actual migration mechanism (a `migrate` hook, an
949
+ * admin-driven flow, batch resolve-time-rewrite, etc.) is a
950
+ * separate design that lands with its own PR. Declaring
951
+ * `version` today does NOT pre-wire a migration body — when the
952
+ * future migration PR ships, plugins that want to provide a
953
+ * migration body will need to re-publish to add it. Existing
954
+ * plugins that omit `version` are unaffected by this addition;
955
+ * plugins that want to participate in the future migration
956
+ * detection path can opt in today by declaring `version: 1`,
957
+ * instead of having to re-publish later just to add the
958
+ * version declaration once the migration PR ships. The
959
+ * migration body itself (and any future `migrate` hook
960
+ * signature) is NOT reserved by this PR.
961
+ *
962
+ * Recommended values: positive integer, start at 1. Declare
963
+ * `version: 1` when the manifest first gains a `version` field,
964
+ * then bump by 1 on each shape-breaking release. Do NOT use
965
+ * `0` / negative numbers / floats — the `number` type accepts
966
+ * them but the semantics for those values are reserved for the
967
+ * future migration PR (`0` may be conflated with "no version
968
+ * declared" / legacy / pre-v1). Skip the field entirely if you
969
+ * do not care about migration support (the default, current
970
+ * behaviour).
971
+ */
972
+ version?: number;
877
973
  }
878
974
  interface AmplessPlugin {
879
975
  name: string;
@@ -934,6 +1030,52 @@ interface AmplessPlugin {
934
1030
  hooks?: {
935
1031
  [K in EventType]?: PluginEventHandler<K>;
936
1032
  };
1033
+ /**
1034
+ * Lifecycle hook called when this plugin is removed from
1035
+ * `cms.config.ts` (Phase 1 reservation: runtime no-op).
1036
+ *
1037
+ * Today the runtime does not detect plugin removal and does not
1038
+ * invoke this hook — orphan data left behind by an uninstalled
1039
+ * plugin must be cleaned up manually by the operator. A future
1040
+ * lifecycle-dispatch PR will need to solve the underlying
1041
+ * problem that a plugin that has been deleted from `cms.config.ts`
1042
+ * no longer has a callable factory in memory — `Config.plugins`
1043
+ * (see `packages/ampless/src/types.ts`) only carries currently-
1044
+ * active plugin objects. Possible future approaches:
1045
+ *
1046
+ * - Two-stage `cms.config.ts` flag — `{ plugin: myPlugin(), pendingRemoval: true }`
1047
+ * keeps the plugin loadable while the runtime calls `uninstall`,
1048
+ * then the operator removes the entry once cleanup succeeds.
1049
+ * - Explicit `npx ampless uninstall <name>` CLI command — keeps
1050
+ * the plugin imported during the call by reading the prior
1051
+ * `cms.config.ts` entry, fires `uninstall`, then mutates the
1052
+ * file.
1053
+ * - Persist prior manifest + `packageName` to DDB/disk so the
1054
+ * runtime can `await import(packageName)` to re-acquire the
1055
+ * factory — assumes the npm package is still installed.
1056
+ *
1057
+ * The exact mechanism is deferred to the lifecycle-dispatch PR.
1058
+ * What this reservation locks in: when `uninstall` does fire,
1059
+ * it runs in a trusted-Lambda IAM context with cleanup grants for
1060
+ * the five plugin-owned data areas (see
1061
+ * docs/architecture/08-plugin-architecture.md
1062
+ * §"Plugin-owned data areas"). Idempotency is the plugin author's
1063
+ * responsibility — the hook may be invoked more than once
1064
+ * (SQS at-least-once or operator-retry).
1065
+ *
1066
+ * **Phase 1 reservation scope**: only the hook name and signature
1067
+ * are reserved. The ctx does NOT yet carry cleanup helpers
1068
+ * (`deletePublicAsset` / `deletePluginSetting` /
1069
+ * `deletePluginSecret`) — writing `await ctx.deletePublicAsset(...)`
1070
+ * today is a TS error. The recommended Phase 1 declaration is an
1071
+ * **empty body** (`async (_ctx) => {}`); the actual cleanup body
1072
+ * lands when the lifecycle-dispatch PR adds the helpers. Plugins
1073
+ * that declared the empty-body uninstall today will pick up the
1074
+ * cleanup invocation events without re-publishing for the signature
1075
+ * change, but a re-publish is required to add the actual cleanup
1076
+ * body.
1077
+ */
1078
+ uninstall?: (ctx: PluginUninstallContext) => Promise<void>;
937
1079
  /**
938
1080
  * Per-post metadata generator. Pure function, called from Next.js
939
1081
  * generateMetadata(). Must not have side effects.
@@ -1551,4 +1693,4 @@ declare function pickDefaultEntrypoint(files: readonly {
1551
1693
 
1552
1694
  declare const VERSION = "0.0.1";
1553
1695
 
1554
- 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 PluginHookResult, 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 };
1696
+ 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 PluginHookResult, 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 PluginUninstallContext, 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 };
@@ -185,7 +185,11 @@ interface AmplessPlugin {
185
185
  publicBodyForPost?(post: Post, ctx): readonly PublicPostBodyDescriptor[]
186
186
  publicHtmlForPost?(post: Post, ctx): readonly PublicPostHtmlDescriptor[]
187
187
  ogImage?: OgImageConfig
188
- settings?: { public?: readonly PluginSettingField[] }
188
+ settings?: {
189
+ public?: readonly PluginSettingField[]
190
+ secret?: readonly PluginSecretField[]
191
+ version?: number // Phase 1 reservation; runtime ignores
192
+ }
189
193
  }
190
194
  ```
191
195
 
@@ -973,6 +977,149 @@ admin は再デプロイなしにいつでも secret をローテーションで
973
977
 
974
978
  ---
975
979
 
980
+ ## 9b. プラグインのデータの保存場所
981
+
982
+ プラグインが所有するデータは以下の 5 つのストレージ領域に置かれる可能性があり、**現状の書き込み経路は領域ごとに異なります**。3 つのファミリに分かれます:
983
+
984
+ - **KvStore** — admin/editor が AppSync 経由で書き込みます。プラグインの hook には KvStore write helper は提供されていません。
985
+ - **PluginSecret + PluginSecretIndicator** — `plugin-secret-handler` Lambda が書き込みます。admin/editor が `setPluginSecret` / `clearPluginSecret` AppSync mutation を呼ぶと、handler Lambda が DDB に書く流れです。trusted processor は `ctx.secret<T>()` で `PluginSecret` を読み取りますが、どちらの secret テーブルにも書き込みません。
986
+ - **S3 `public/plugins/{instanceId ?? name}/*`** — trusted Lambda の hook context (`ctx.writePublicAsset(...)`) から書き込みます。プラグインの hook が直接書き込めるのはこの領域だけです。
987
+
988
+ それ以外 — `Post`、`Page`、`Media`、`PostTag` DynamoDB テーブル、`public/site-settings.json` S3 ミラー、他プラグインの namespace — への書き込みは禁止です。現状 runtime が強制しているわけではなく、信頼(および将来の IAM 強化)によって担保されます。
989
+
990
+ | 領域 | パス / 識別子 | アクセスレベル | Phase |
991
+ |---|---|---|---|
992
+ | KvStore(admin 設定) | DynamoDB `pk='siteconfig'`、`sk='plugins.<instanceId>.<fieldKey>'` | admin/editor が AppSync 経由で書く(プラグインの hook context には KvStore write helper は現状提供されていない) | Phase 2 |
993
+ | KvStore(runtime 状態/キャッシュ) | DynamoDB `pk='pluginstate:<plugin>:...'`(TTL 任意) | admin/editor が AppSync 経由で書く(プラグインの hook context には KvStore write helper は現状提供されていない) | 現行 |
994
+ | PluginSecret | DynamoDB `PluginSecret` テーブル、`sk='plugins.<instanceId>.<fieldKey>'` | `trusted` 限定(IAM 専用 AppSync 認証) | Phase 6a |
995
+ | PluginSecretIndicator | DynamoDB `PluginSecretIndicator` テーブル、`sk='plugins.<instanceId>.<fieldKey>'` | `trusted` + admin/editor(indicator 読み取り) | Phase 6a |
996
+ | S3 プラグイン成果物 | `public/plugins/{instanceId ?? name}/*` | `trusted` 限定(`writePublicAsset`) | Phase 3 |
997
+
998
+ **cleanup は自動ではありません。** `cms.config.ts` からプラグインを外しても、5 領域のデータは自動削除されません。将来の lifecycle-dispatch PR が `uninstall` フックの起動メカニズムを追加するまで(§9c 参照)、オペレータによる手動削除が必要です。
999
+
1000
+ **独自 DynamoDB テーブル。** プラグインが ampless スキーマ外に独自の DynamoDB テーブルを持つ場合、lifecycle 管理(アンインストール時の cleanup を含む)はプラグイン著者の責任です。ampless は外部テーブルを把握しておらず、将来の `uninstall` cleanup grant は上記 5 領域のみをカバーします。
1001
+
1002
+ 詳細な設計根拠と IAM grant 設計については [`docs/architecture/08-plugin-architecture.ja.md`](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.ja.md#プラグインが所有するデータ領域) を参照してください。
1003
+
1004
+ ---
1005
+
1006
+ ## 9c. `uninstall` フック(Phase 1 予約)
1007
+
1008
+ `AmplessPlugin.uninstall` は **Phase 1 型予約** です — 現在 runtime はこれを呼び出しません。hook 名とシグネチャをプラグインコードが出回る前に確定し、将来の lifecycle-dispatch PR が名前・形を変更せずに起動を配線できるようにするのが目的です。
1009
+
1010
+ **Phase 1 スコープ**: hook 名とシグネチャのみ予約。`ctx` には cleanup helper (`deletePublicAsset` / `deletePluginSetting` / `deletePluginSecret`) がまだありません — 今日 `await ctx.deletePublicAsset(...)` と書くと TypeScript エラーです。これらの helper が lifecycle-dispatch PR で追加される際は `PluginUninstallContext` への追加(additive)であり、空のボディを宣言済みのプラグインへの破壊変更はありません。
1011
+
1012
+ **今日の推奨宣言** — 空のボディ:
1013
+
1014
+ ```ts
1015
+ // 例: S3 成果物と secret を書く trusted plugin
1016
+ definePlugin({
1017
+ name: 'my-trusted-plugin',
1018
+ apiVersion: 1,
1019
+ trust_level: 'trusted',
1020
+ capabilities: ['eventHooks', 'writePublicAsset', 'secretSettings'],
1021
+ hooks: { 'content.published': async (_evt, ctx) => { /* ... */ } },
1022
+ uninstall: async (_ctx) => {
1023
+ // Phase 1 予約: runtime は今日このフックを呼び出しません。
1024
+ // また `ctx` には cleanup helper
1025
+ // (`deletePublicAsset` / `deletePluginSetting` /
1026
+ // `deletePluginSecret`) がまだありません。
1027
+ // 空ボディを宣言しておくのが forward-compat の推奨形です —
1028
+ // 将来 lifecycle-dispatch PR がリリースされたとき、helper が
1029
+ // `PluginUninstallContext` に追加され、そこで cleanup ボディを
1030
+ // 実装します。今日の空宣言を再パブリッシュしなくても呼び出し
1031
+ // イベントは受け取れますが、実際の cleanup ボディを追加するには
1032
+ // 再パブリッシュが必要です。
1033
+ },
1034
+ })
1035
+ ```
1036
+
1037
+ **冪等性。** lifecycle-dispatch PR がリリースされると、`uninstall` フックは trusted Lambda の IAM コンテキストで実行されます。SQS 配送は at-least-once なので cleanup ボディが複数回実行される可能性があります。安全にリトライできるよう設計してください(S3 の `deleteObject` は冪等、DDB の conditional delete も key が消えていれば safe)。
1038
+
1039
+ ---
1040
+
1041
+ ## 9d. settings の形状を変えるとき(Phase 1 予約)
1042
+
1043
+ ### 今日の挙動: `public` と `secret` は別の経路を辿る
1044
+
1045
+ 形状変更は今日の runtime で silently 吸収されますが、実際の挙動は `settings.public` と `settings.secret` で異なります。両者は完全に別の write/read パスを使っているためです。
1046
+
1047
+ #### `settings.public`(`resolvePluginSettings` の寛容な resolver)
1048
+
1049
+ `resolvePluginSettings`([packages/ampless/src/plugin-settings.ts](packages/ampless/src/plugin-settings.ts))は `manifest.public` のみをイテレートし、field ごとに `field.default` にフォールバックします。resolver は `manifest.secret` を一切見ません。
1050
+
1051
+ | 変更 | 今日の挙動(public フィールド) |
1052
+ |---|---|
1053
+ | **フィールド追加** | 新フィールドは `manifest.default` から解決されます。ストレージに値がないため default が使われます。 |
1054
+ | **フィールド削除** | KvStore に orphan row が残ります。`resolvePluginSettings` は現在の manifest にない key を silently skip します。 |
1055
+ | **フィールド改名**(`endpoint` → `url`) | 削除 + 追加として扱われます。旧値は到達不能(orphan)になり、新フィールドは `default` から解決されます。 |
1056
+ | **型を非互換に変更** | 新しい validator がストレージの値に対して実行されます。通過すれば値が使われ、失敗すれば `default`(または `undefined`)にフォールバックします。 |
1057
+
1058
+ #### `settings.secret`(admin UI + `PluginSecret` + `ctx.secret()`、寛容な resolver なし)
1059
+
1060
+ Secret フィールドは `resolvePluginSettings` から一切読まれません。別の経路を辿ります:
1061
+
1062
+ - admin UI が `setPluginSecret` AppSync mutation 経由で値を 1 つずつ書き、`plugin-secret-handler` Lambda が暗号化して `PluginSecret` DynamoDB テーブルに格納
1063
+ - trusted hook が `ctx.secret<T>(key)` で key 単位で直接 `PluginSecret` から復号読み取り
1064
+ - `PluginSecretField` 型は `default` を持てない(型レベルで禁止)。manifest レベルのフォールバックは存在しない
1065
+
1066
+ | 変更 | 今日の挙動(secret フィールド) |
1067
+ |---|---|
1068
+ | **フィールド追加** | admin UI に新フィールドが表示されます。admin が値を設定するまで `ctx.secret<T>(key)` は `undefined` を返します。 |
1069
+ | **フィールド削除** | admin UI から消えますが、`PluginSecret` 内の暗号化された row は orphan として残ります。resolver は走らないため、operator が手動で削除する必要があります。 |
1070
+ | **フィールド改名** | 旧 key の暗号化された row は orphan になります(resolver / cleanup なし)。新 key は未設定として表示され、admin が新 key に値を入れ直す必要があります。 |
1071
+ | **型を非互換に変更** | `validatePluginSettingValue` は write 時のみ実行されます。既存の暗号化値は read 時には影響を受けず、`ctx.secret<T>(key)` は最後に書かれた値を返します。新しい入力を admin が保存しようとすると新 validator で reject される、というだけです。 |
1072
+
1073
+ これらのケースでエラーや警告は発生しません。形状変更後の挙動を確認するには、プラグイン著者が手動でストレージの値を確認する必要があります。
1074
+
1075
+ ### `version` 予約について
1076
+
1077
+ `PluginSettingsManifest.version?: number` は **Phase 1 型予約** です。runtime は今日このフィールドを読みません。宣言しても上記の寛容な resolver の挙動は変わりません。
1078
+
1079
+ この予約は、将来の migration PR がストレージの値に manifest の version をどこかに保存し、resolve 時に `manifest.version` と比較してミスマッチを検出できるようにするためのものです。ミスマッチ時の応答(warn / skip / in-place migration / admin 主導フローなど)はその future PR の設計領域です。
1080
+
1081
+ **`version` を宣言しても今日保証されないこと:**
1082
+
1083
+ - migration ボディは実行されません。
1084
+ - ストレージの値の再検証・再 default も行われません。
1085
+ - `migrate` hook のシグネチャは予約されていません(それは別の future 設計)。
1086
+
1087
+ **`version` を今日宣言することで得られるもの:**
1088
+
1089
+ - その PR がリリースされた後に `version` フィールドを追加するためだけの再パブリッシュが不要になります(将来の migration 検出パスに最初から参加できます)。
1090
+ - manifest に versioned な形状であることを将来のメンテナーに伝えます。
1091
+
1092
+ ### 推奨パターン
1093
+
1094
+ | シナリオ | 推奨 |
1095
+ |---|---|
1096
+ | 追加のみの変更(新しいオプションフィールド、default あり) | `version` の bump 不要。寛容な resolver が吸収します。 |
1097
+ | 非互換変更(改名、型変更、意味的変化) | `version` を 1 増やします。 |
1098
+ | 既存 manifest に初めて `version` を追加する | `version: 1` から始めます。 |
1099
+
1100
+ **正の整数、1 始まりを使用してください。** `0`、負数、小数は使わないでください。`number` 型はそれらを受け入れますが、将来の migration PR が `0` / undefined に特別な意味(legacy / pre-v1 との混同を避けるため)を予約する可能性があります。
1101
+
1102
+ ### コード例
1103
+
1104
+ ```ts
1105
+ definePlugin({
1106
+ name: 'my-plugin',
1107
+ apiVersion: 1,
1108
+ trust_level: 'untrusted',
1109
+ capabilities: ['adminSettings'],
1110
+ settings: {
1111
+ version: 2, // ← Phase 1 reservation。今日の runtime は無視します。
1112
+ public: [
1113
+ { type: 'url', key: 'webhookUrl', label: 'Webhook URL', required: true },
1114
+ ],
1115
+ },
1116
+ })
1117
+ ```
1118
+
1119
+ 将来の migration PR がリリースされると、すでに `version` を宣言しているプラグインは自動的に検出パスに乗ります。実際の migration ボディを提供したいプラグインは、その PR がリリースされてから再パブリッシュしてボディを追加する必要があります。`version` を省略したプラグインは引き続き現在の寛容な resolver の挙動のままで変化ありません。
1120
+
1121
+ ---
1122
+
976
1123
  ## 10. ウォークスルー: GA4 を Phase 1 から Phase 2 に移行する
977
1124
 
978
1125
  Phase 1 の GA4 プラグインは measurement ID を constructor 引数で受けていました。Phase 2 では後方互換のためにその引数を残しつつ、値は `ctx.setting()` 経由で読みます。
@@ -213,7 +213,11 @@ interface AmplessPlugin {
213
213
  publicBodyForPost?(post: Post, ctx): readonly PublicPostBodyDescriptor[]
214
214
  publicHtmlForPost?(post: Post, ctx): readonly PublicPostHtmlDescriptor[]
215
215
  ogImage?: OgImageConfig
216
- settings?: { public?: readonly PluginSettingField[] }
216
+ settings?: {
217
+ public?: readonly PluginSettingField[]
218
+ secret?: readonly PluginSecretField[]
219
+ version?: number // Phase 1 reservation; runtime ignores
220
+ }
217
221
  }
218
222
  ```
219
223
 
@@ -1323,6 +1327,208 @@ describe('webhookPlugin signing', () => {
1323
1327
 
1324
1328
  ---
1325
1329
 
1330
+ ## 9b. Where your plugin's data lives
1331
+
1332
+ Plugin-owned data may live in the five storage areas listed below; the
1333
+ **current write paths differ by area** and fall into three families:
1334
+
1335
+ - **KvStore** — admin/editor write through AppSync. Plugin hooks have no
1336
+ KvStore write helper today.
1337
+ - **PluginSecret + PluginSecretIndicator** — written by the
1338
+ `plugin-secret-handler` Lambda, which is invoked by admin/editor through
1339
+ the `setPluginSecret` / `clearPluginSecret` AppSync mutations. The
1340
+ trusted processor reads `PluginSecret` via `ctx.secret<T>()` but does
1341
+ NOT write to either secret table.
1342
+ - **S3 `public/plugins/{instanceId ?? name}/*`** — written by the trusted
1343
+ Lambda's hook context (`ctx.writePublicAsset(...)`). This is the only
1344
+ area a plugin hook writes to directly today.
1345
+
1346
+ All other areas — the `Post`, `Page`, `Media`, and `PostTag` DynamoDB
1347
+ tables, the `public/site-settings.json` S3 mirror, and any other plugin's
1348
+ namespace — are off-limits. The runtime does not enforce this today; it is
1349
+ a contract enforced by trust (and future IAM hardening).
1350
+
1351
+ | Area | Path / identifier | Access level | Phase |
1352
+ |---|---|---|---|
1353
+ | KvStore (admin settings) | DynamoDB `pk='siteconfig'`, `sk='plugins.<instanceId>.<fieldKey>'` | admin/editor via AppSync; plugin hook write helper is not provided today | Phase 2 |
1354
+ | KvStore (runtime state/cache) | DynamoDB `pk='pluginstate:<plugin>:...'` with optional TTL | admin/editor via AppSync; plugin hook write helper is not provided today | Current |
1355
+ | PluginSecret | DynamoDB `PluginSecret` table, `sk='plugins.<instanceId>.<fieldKey>'` | `trusted` only (IAM-only AppSync auth) | Phase 6a |
1356
+ | PluginSecretIndicator | DynamoDB `PluginSecretIndicator` table, `sk='plugins.<instanceId>.<fieldKey>'` | `trusted` + admin/editor (read indicator) | Phase 6a |
1357
+ | S3 plugin assets | `public/plugins/{instanceId ?? name}/*` | `trusted` only (`writePublicAsset`) | Phase 3 |
1358
+
1359
+ **Cleanup is not automatic.** Removing a plugin from `cms.config.ts` leaves
1360
+ orphan data in all five areas. Manual operator cleanup is required until the
1361
+ future lifecycle-dispatch PR ships the invocation mechanism for the `uninstall`
1362
+ hook (see §9c below).
1363
+
1364
+ **Custom DynamoDB tables.** If your plugin provisions its own DynamoDB table
1365
+ outside the ampless schema, lifecycle management (including cleanup on uninstall)
1366
+ is your responsibility. ampless has no visibility into external tables and the
1367
+ future `uninstall` cleanup grants cover only the five areas above.
1368
+
1369
+ See [`docs/architecture/08-plugin-architecture.md`](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.md#plugin-owned-data-areas)
1370
+ for the full rationale and the IAM grant design.
1371
+
1372
+ ---
1373
+
1374
+ ## 9c. `uninstall` hook (Phase 1 reservation)
1375
+
1376
+ `AmplessPlugin.uninstall` is a **Phase 1 type reservation** — the runtime does
1377
+ not call it today. Its purpose is to lock in the hook name and signature before
1378
+ any plugin code ships, so the future lifecycle-dispatch PR can wire the
1379
+ invocation without renaming or reshaping anything.
1380
+
1381
+ **Phase 1 scope**: only the hook name and signature are reserved. The `ctx`
1382
+ does **not** yet carry cleanup helpers (`deletePublicAsset` /
1383
+ `deletePluginSetting` / `deletePluginSecret`) — writing
1384
+ `await ctx.deletePublicAsset(...)` today is a TypeScript error. When those
1385
+ helpers land (in the lifecycle-dispatch PR), they are added to
1386
+ `PluginUninstallContext` additively, with no breaking change to plugins that
1387
+ declared an empty body in advance.
1388
+
1389
+ **Recommended declaration today** — an empty body:
1390
+
1391
+ ```ts
1392
+ // Example: a trusted plugin that writes assets and stores secrets
1393
+ definePlugin({
1394
+ name: 'my-trusted-plugin',
1395
+ apiVersion: 1,
1396
+ trust_level: 'trusted',
1397
+ capabilities: ['eventHooks', 'writePublicAsset', 'secretSettings'],
1398
+ hooks: { 'content.published': async (_evt, ctx) => { /* ... */ } },
1399
+ uninstall: async (_ctx) => {
1400
+ // Phase 1 reservation: the runtime does not invoke this hook
1401
+ // today, AND `ctx` does not yet carry cleanup helpers
1402
+ // (`deletePublicAsset` / `deletePluginSetting` /
1403
+ // `deletePluginSecret`). Declaring an empty body is the
1404
+ // recommended forward-compat shape — when the future
1405
+ // lifecycle-dispatch PR ships, the helpers land on
1406
+ // `PluginUninstallContext` additively, and you fill in the
1407
+ // body THEN. Plugins that shipped the empty declaration
1408
+ // today do not need to re-publish for the signature change,
1409
+ // but a re-publish is required to add the actual cleanup
1410
+ // body.
1411
+ },
1412
+ })
1413
+ ```
1414
+
1415
+ **Idempotency.** When the lifecycle-dispatch PR ships, the `uninstall` hook
1416
+ runs in a trusted-Lambda IAM context. The SQS delivery is at-least-once, so
1417
+ your cleanup body may run more than once — design it to be safe to retry
1418
+ (e.g. `deleteObject` is idempotent on S3; a conditional `delete` on DDB is
1419
+ safe if the key is already gone).
1420
+
1421
+ ---
1422
+
1423
+ ## 9d. When you change settings shape (Phase 1 reservation)
1424
+
1425
+ ### What happens today: `public` and `secret` travel through different paths
1426
+
1427
+ Shape changes are absorbed silently by today's runtime, but the actual
1428
+ behaviour differs between `settings.public` and `settings.secret` because
1429
+ they use entirely different write/read paths.
1430
+
1431
+ #### `settings.public` (lenient resolver via `resolvePluginSettings`)
1432
+
1433
+ `resolvePluginSettings` ([packages/ampless/src/plugin-settings.ts](packages/ampless/src/plugin-settings.ts))
1434
+ iterates `manifest.public` and falls back to `field.default` per field. The
1435
+ resolver never looks at `manifest.secret`.
1436
+
1437
+ | Change | Behaviour today (public fields) |
1438
+ |---|---|
1439
+ | **Field added** | New field resolves via `manifest.default` — stored value is absent, default takes over. |
1440
+ | **Field deleted** | Orphan row remains in KvStore. `resolvePluginSettings` silently skips keys that are not in the current manifest. |
1441
+ | **Field renamed** (`endpoint` → `url`) | Treated as deletion + addition: old value is unreachable (orphan), new field resolves via `default`. |
1442
+ | **Type changed incompatibly** | New validator runs on the stored value. If it passes, the value is used. If it fails, falls through to `default` (or `undefined`). |
1443
+
1444
+ #### `settings.secret` (admin UI + `PluginSecret` + `ctx.secret()`, no lenient resolver)
1445
+
1446
+ Secret fields are never read by `resolvePluginSettings`. They travel a
1447
+ separate path:
1448
+
1449
+ - The admin UI writes individual values through the `setPluginSecret`
1450
+ AppSync mutation, which the `plugin-secret-handler` Lambda encrypts and
1451
+ stores in the `PluginSecret` DynamoDB table.
1452
+ - Trusted hooks read them individually by key via `ctx.secret<T>(key)`,
1453
+ which goes directly to `PluginSecret` and decrypts.
1454
+ - The `PluginSecretField` type forbids `default` — there is no
1455
+ manifest-level fallback for secret values.
1456
+
1457
+ | Change | Behaviour today (secret fields) |
1458
+ |---|---|
1459
+ | **Field added** | New field appears in the admin UI. Until an admin sets a value, `ctx.secret<T>(key)` returns `undefined`. |
1460
+ | **Field deleted** | The field disappears from the admin UI, but the encrypted row in `PluginSecret` is orphaned. No resolver runs over it; an operator must delete it manually. |
1461
+ | **Field renamed** | Old key's encrypted row is orphaned (no resolver / cleanup). The new key shows up unset. Admin must re-enter the value under the new key. |
1462
+ | **Type changed incompatibly** | `validatePluginSettingValue` only runs at write time, so an existing stored ciphertext is unaffected on read; `ctx.secret<T>(key)` returns whatever was last written. A re-validate on admin save would reject incompatible new input. |
1463
+
1464
+ No error or warning is produced in any of these cases. Plugin authors must
1465
+ inspect their stored values manually if they need to verify behaviour
1466
+ after a shape change.
1467
+
1468
+ ### The `version` reservation
1469
+
1470
+ `PluginSettingsManifest.version?: number` is a **Phase 1 type
1471
+ reservation** — the runtime does NOT read it today. Declaring it has no
1472
+ effect on the lenient resolver above.
1473
+
1474
+ The reservation exists so that a future migration PR may persist the
1475
+ active manifest version somewhere alongside stored values and compare it to
1476
+ `manifest.version` at resolve time to detect mismatch. The exact mismatch
1477
+ response (warn / skip / migrate in-place / trigger an admin-driven flow) is
1478
+ design territory for that future PR.
1479
+
1480
+ **What declaring `version` today does NOT promise:**
1481
+
1482
+ - It does NOT trigger any migration body.
1483
+ - It does NOT cause the runtime to re-validate or re-default stored values.
1484
+ - It does NOT reserve a `migrate` hook signature — that is a separate future
1485
+ design.
1486
+
1487
+ **What declaring `version` today does do:**
1488
+
1489
+ - It positions the plugin to be picked up by the future migration detection
1490
+ path, without requiring a re-publish just to add the `version` field once
1491
+ that PR ships.
1492
+ - It communicates intent to future maintainers that this manifest has a
1493
+ versioned shape.
1494
+
1495
+ ### Recommended pattern
1496
+
1497
+ | Scenario | Recommendation |
1498
+ |---|---|
1499
+ | Additive change only (new optional field, default provided) | No `version` bump needed. Lenient resolver handles it. |
1500
+ | Non-additive change (rename, incompatible type change, semantic shift) | Bump `version` by 1. |
1501
+ | First time adding `version` to an existing manifest | Start at `version: 1`. |
1502
+
1503
+ **Use positive integers, starting at 1.** Do NOT use `0`, negative numbers,
1504
+ or floats — the `number` type accepts them but the future migration PR may
1505
+ reserve special semantics for `0` / undefined (legacy / pre-v1 conflation).
1506
+
1507
+ ### Code example
1508
+
1509
+ ```ts
1510
+ definePlugin({
1511
+ name: 'my-plugin',
1512
+ apiVersion: 1,
1513
+ trust_level: 'untrusted',
1514
+ capabilities: ['adminSettings'],
1515
+ settings: {
1516
+ version: 2, // ← Phase 1 reservation. Today runtime ignores.
1517
+ public: [
1518
+ { type: 'url', key: 'webhookUrl', label: 'Webhook URL', required: true },
1519
+ ],
1520
+ },
1521
+ })
1522
+ ```
1523
+
1524
+ When a future migration PR ships, plugins that have already declared
1525
+ `version` will be on the detection path automatically. Plugins that want to
1526
+ provide an actual migration body will need to re-publish after that PR ships
1527
+ to add the body. Plugins that omit `version` entirely continue with the
1528
+ current lenient-resolver behaviour — no change.
1529
+
1530
+ ---
1531
+
1326
1532
  ## 10. Walk-through: migrating GA4 from Phase 1 to Phase 2
1327
1533
 
1328
1534
  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.35",
3
+ "version": "1.0.0-alpha.37",
4
4
  "description": "Serverless CMS for AWS Amplify",
5
5
  "license": "MIT",
6
6
  "type": "module",