create-ampless 1.0.0-alpha.99 → 1.0.0-beta.149

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.
Files changed (34) hide show
  1. package/README.ja.md +4 -4
  2. package/README.md +4 -4
  3. package/dist/index.js +198 -29
  4. package/dist/templates/_shared/README.ja.md +4 -4
  5. package/dist/templates/_shared/README.md +4 -4
  6. package/dist/templates/_shared/amplify/backend.ts +1 -1
  7. package/dist/templates/_shared/amplify/data/get-published-post.js +13 -1
  8. package/dist/templates/_shared/amplify/data/list-posts-by-tag.js +15 -3
  9. package/dist/templates/_shared/amplify/data/list-published-posts.js +26 -15
  10. package/dist/templates/_shared/amplify/events/dispatcher/handler.ts +11 -1
  11. package/dist/templates/_shared/amplify/secrets/encryption-key.ts +2 -2
  12. package/dist/templates/_shared/app/(admin)/admin/_editor-bootstrap.tsx +13 -0
  13. package/dist/templates/_shared/app/(admin)/admin/layout.tsx +2 -1
  14. package/dist/templates/_shared/app/(admin)/admin/preview/route.tsx +4 -0
  15. package/dist/templates/_shared/cms.config.ts +3 -0
  16. package/dist/templates/_shared/docs/plugin-author-guide.ja.md +841 -31
  17. package/dist/templates/_shared/docs/plugin-author-guide.md +1147 -80
  18. package/dist/templates/_shared/package.json +17 -16
  19. package/dist/templates/_shared/plugins/README.ja.md +1 -1
  20. package/dist/templates/_shared/plugins/README.md +1 -1
  21. package/dist/templates/blog/pages/home.tsx +5 -5
  22. package/dist/templates/blog/pages/post.tsx +5 -3
  23. package/dist/templates/corporate/pages/home.tsx +5 -5
  24. package/dist/templates/corporate/pages/post.tsx +5 -3
  25. package/dist/templates/dads/pages/home.tsx +5 -5
  26. package/dist/templates/dads/pages/post.tsx +5 -3
  27. package/dist/templates/docs/pages/post.tsx +5 -3
  28. package/dist/templates/landing/pages/home.tsx +5 -5
  29. package/dist/templates/landing/pages/post.tsx +5 -3
  30. package/dist/templates/minimal/pages/post.tsx +5 -3
  31. package/dist/templates/plugin-local/README.md +1 -1
  32. package/dist/templates/plugin-standalone/README.ja.md +3 -3
  33. package/dist/templates/plugin-standalone/README.md +3 -3
  34. package/package.json +1 -1
@@ -19,6 +19,8 @@ per-post body injection, the async event hooks, and admin-managed
19
19
  The design rationale is in [`docs/architecture/08-plugin-architecture.md`](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.md);
20
20
  this page is the hands-on companion.
21
21
 
22
+ > **Positioning**: ampless is a customization-based CMS for engineers — plugins are **npm dependencies that the site engineer imports + configures in `cms.config.ts`**. The engineer audits each dep before installing, the way they would for any other npm library (Astro integration / Next.js plugin pattern). The trust framework described in this guide (`trust_level`, capabilities, IAM-scoped Lambdas) is implemented in v1 as **first-party plugin organization** — it decides which trust tier's Lambda runs each event hook, which IAM permissions each tier holds, and applies hard runtime gates only at narrowly-scoped points (most notably: `settings.secret` requires `trust_level: 'trusted'` because secret read needs the trusted Lambda's IAM permission). Most capability declarations are soft warnings + admin labels + future allow-list surfaces, not hard runtime gates. It is **not** designed as a marketplace-grade automatic sandbox that safely runs arbitrary untrusted third-party plugins. Marketplace + runtime sandbox is a v2.0+ exploration, not a v1 guarantee. See [`docs/architecture/08-plugin-architecture.md#trust-model-v1-scope`](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.md#trust-model-v1-scope) for the full trust model.
23
+
22
24
  ---
23
25
 
24
26
  ## 0. Theme vs Plugin Boundary
@@ -75,7 +77,7 @@ same surfaces. The differences are packaging, distribution, and the
75
77
  opt-in install-time validation that the static `package.json#amplessPlugin`
76
78
  manifest enables (see §3 below).
77
79
 
78
- §14 below has a one-command scaffold (`npx create-ampless plugin <name>`)
80
+ §14 below has a one-command scaffold (`npx create-ampless@beta plugin <name>`)
79
81
  that produces ready-to-use boilerplate for either of the latter two.
80
82
 
81
83
  An ampless plugin is a TypeScript module that returns an
@@ -121,10 +123,10 @@ The fastest way to create a plugin is to scaffold one:
121
123
 
122
124
  ```bash
123
125
  # Site-local (writes plugins/<name>/index.ts in the current ampless site)
124
- npx create-ampless@latest plugin my-thing
126
+ npx create-ampless@beta plugin my-thing
125
127
 
126
128
  # Standalone npm package (writes ./<name>/ ready for `npm publish`)
127
- npx create-ampless@latest plugin @myscope/ampless-plugin-my-thing --standalone
129
+ npx create-ampless@beta plugin @myscope/ampless-plugin-my-thing --standalone
128
130
  ```
129
131
 
130
132
  §14 covers the full flow. The rest of this section explains what the
@@ -200,7 +202,7 @@ That's it. Restart `npm run dev`, view source on any page, and the
200
202
  interface AmplessPlugin {
201
203
  name: string // package-like identifier, e.g. 'analytics-ga4'
202
204
  packageName?: string // npm package name for install-time cross-check
203
- apiVersion: 1 // bump only when the contract changes
205
+ apiVersion: 1 // the only valid value today
204
206
  trust_level: 'untrusted' | 'trusted' | 'privileged'
205
207
  instanceId?: string // namespace for multi-instance installs
206
208
  displayName?: LocalizedString // admin UI label
@@ -213,7 +215,11 @@ interface AmplessPlugin {
213
215
  publicBodyForPost?(post: Post, ctx): readonly PublicPostBodyDescriptor[]
214
216
  publicHtmlForPost?(post: Post, ctx): readonly PublicPostHtmlDescriptor[]
215
217
  ogImage?: OgImageConfig
216
- settings?: { public?: readonly PluginSettingField[] }
218
+ settings?: {
219
+ public?: readonly PluginSettingField[]
220
+ secret?: readonly PluginSecretField[]
221
+ version?: number // Phase 1 reservation; runtime ignores
222
+ }
217
223
  }
218
224
  ```
219
225
 
@@ -230,6 +236,23 @@ There's only one version today. Future breaking-change versions will
230
236
  bump this number; the runtime rejects unknown values rather than
231
237
  silently mis-binding.
232
238
 
239
+ **Today only `apiVersion: 1` is valid.** The literal type accepts
240
+ no other value, and the runtime hard-throws if `package.json#amplessPlugin.apiVersion`
241
+ disagrees with what `definePlugin()` returns or exceeds the runtime's
242
+ `SUPPORTED_API_VERSION`.
243
+
244
+ All Phase 1 compat-break reservations (PRs #220, #222, #230, #232,
245
+ #234) live within `apiVersion: 1` — declaring or not declaring them
246
+ in your plugin does not affect the contract version.
247
+
248
+ If a future `apiVersion: 2` is ever introduced, it will be announced
249
+ through a changeset and a section update in this guide and the
250
+ architecture doc. Until then, **publish your plugin with
251
+ `apiVersion: 1` and treat it as the only valid value**. See the
252
+ [apiVersion bump policy](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.md#apiversion-bump-policy)
253
+ section in the architecture doc for the full criteria around what
254
+ would (and would not) trigger a v2 bump.
255
+
233
256
  ### `instanceId`
234
257
 
235
258
  Optional, defaults to `name`. When you ship a plugin that can run
@@ -337,15 +360,29 @@ not a runtime block.
337
360
 
338
361
  ## 4. Picking a `trust_level`
339
362
 
363
+ The trust tiers are implemented in v1 as **first-party plugin organization** — they decide which IAM-scoped Lambda runs your event hooks and which permissions that Lambda holds. This is a code organization surface for engineer-audited npm deps, not a marketplace-grade automatic sandbox for arbitrary third-party untrusted plugins (see the [Positioning note](#writing-an-ampless-plugin) above and the [full trust model](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.md#trust-model-v1-scope)).
364
+
340
365
  Three tiers, picked by what the plugin needs to do **inside
341
366
  event hooks** (the sync surfaces — metadata, head, body — don't
342
367
  touch IAM):
343
368
 
344
- | Tier | IAM | Used by |
345
- |---|---|---|
346
- | `untrusted` | none (SQS consume only) | head/body descriptors, webhook delivery, content transforms |
347
- | `trusted` | read posts, write `public/plugins/<instanceId ?? name>/...` | RSS feed, sitemap, computed JSON indexes |
348
- | `privileged` | reserved | future: SES, secrets, private S3 |
369
+ | Tier | IAM | What runs today | Used by |
370
+ |---|---|---|---|
371
+ | `untrusted` | none (SQS consume only) | sync surfaces + event hooks | head/body descriptors, webhook delivery, content transforms |
372
+ | `trusted` | read posts, write `public/plugins/<instanceId ?? name>/...` | sync surfaces + event hooks | RSS feed, sitemap, computed JSON indexes |
373
+ | `privileged` | reserved (v2.0+ exploration only) | sync surfaces only — event hooks are silently filtered out (warning logged) | future marketplace exploration: SES, secrets, private S3 |
374
+
375
+ > **Warning for `privileged` plugin authors:** If you declare
376
+ > `trust_level: 'privileged'` with event `hooks` today, **your hooks
377
+ > WILL NOT EXECUTE**. Both event processors filter out privileged
378
+ > plugins and emit a `console.warn` on every matching SQS event so
379
+ > the drop is visible. Sync render surfaces (`publicHead`,
380
+ > `publicBodyEnd`, `metadata`, `publicBodyForPost`, `publicHtmlForPost`)
381
+ > work normally regardless of `trust_level` and emit no warning.
382
+ > `privileged` Lambda provisioning is a v2.0+ exploration item — if
383
+ > AmpLess later builds a plugin marketplace, that PR will automatically
384
+ > pick up plugins that already declared `'privileged'`.
385
+ > v1 first-party plugins should use `trust_level: 'trusted'` for capabilities that fit within the trusted Lambda's existing IAM scope: Post / KvStore / PluginSecret / PostTag read, `public/plugins/*` S3 write, and outbound HTTP (no AWS-IAM-authenticated calls). Requirements that fall outside that scope — SES, private S3 prefixes, calling AWS APIs that need an IAM principal of their own — are v2.0+ privileged-Lambda exploration material and don't fit into v1 `trusted`.
349
386
 
350
387
  Rule of thumb:
351
388
 
@@ -540,8 +577,12 @@ a console warning** and never rendered.
540
577
  index numbers no one can map back to a plugin.
541
578
  - **Duplicate `id`**: the last occurrence wins. A dev warning prints
542
579
  which key was duplicated.
543
- - **CSP nonce**: not propagated in Phase 1. The `nonce` attr is
544
- declared in the type for forward-compat but discarded today.
580
+ - **CSP nonce**: `inlineScript.nonce` and `script.nonce` are accepted
581
+ by the type (`'auto'` is the sentinel for future runtime stamping;
582
+ any string is an explicit literal). Phase 1 reservation: the runtime
583
+ accepts the field but does not propagate it to the rendered element.
584
+ See [CSP nonce (Phase 1 reservation)](#csp-nonce-phase-1-reservation)
585
+ below.
545
586
  - **Strategy**: `afterInteractive` adds `async` to external scripts.
546
587
  `lazyOnload` adds `defer`. Explicit `async` / `defer` always
547
588
  wins. `beforeInteractive` is not supported.
@@ -637,7 +678,7 @@ the slots:
637
678
  ```tsx
638
679
  {postBody} {/* publicBodyForPost — JSON-LD */}
639
680
  {html.beforeContent} {/* publicHtmlForPost — beforeContent slot */}
640
- <div className="prose" dangerouslySetInnerHTML={{ __html: renderBody(post) }} />
681
+ <div className="prose">{await ampless.renderBody(post)}</div>
641
682
  {html.afterContent} {/* publicHtmlForPost — afterContent slot */}
642
683
  ```
643
684
 
@@ -717,10 +758,731 @@ nothing races against hydration.
717
758
 
718
759
  ---
719
760
 
761
+ ## 6a. Scheduled posts and content events
762
+
763
+ ampless supports **scheduled publishing**: a post with `status:
764
+ 'published'` and a future `publishedAt` is hidden from all public
765
+ reads until that time. Once `publishedAt` arrives, the post becomes
766
+ visible within the site's natural cache window (≤ ~5 minutes by
767
+ default) — there is no exact-time trigger.
768
+
769
+ ### Events fire at save time, not at `publishedAt`
770
+
771
+ `content.published` (and `content.updated`) are emitted via DynamoDB
772
+ Streams **when the post is saved**, not when `publishedAt` arrives.
773
+ This means a plugin that reacts to `content.published` will run
774
+ **before the post is publicly visible** when the post is future-dated.
775
+
776
+ For trusted plugins that rebuild public assets from the current post
777
+ list (RSS, sitemap, JSON indexes), this is harmless — `listPublishedPosts()`
778
+ already filters out future-dated posts, so the regenerated asset
779
+ simply omits the scheduled post until it goes live.
780
+
781
+ For **outbound-notification plugins** (webhook, push notification,
782
+ social post) the early fire matters: the notification will be
783
+ delivered to subscribers while the post URL still returns 404 or
784
+ redirects to the home page.
785
+
786
+ ### Recommended pattern: gate on `publishedAt`
787
+
788
+ Check `event.payload.publishedAt` before dispatching. Skip or defer
789
+ when the post is future-dated:
790
+
791
+ ```ts
792
+ hooks: {
793
+ async 'content.published'(event, ctx) {
794
+ const { publishedAt } = event.payload
795
+
796
+ // Skip notification for future-scheduled posts. The event fires
797
+ // at save time, but the post won't be public until publishedAt.
798
+ if (publishedAt && new Date(publishedAt) > new Date()) {
799
+ return
800
+ }
801
+
802
+ // Post is live now — safe to notify.
803
+ await sendWebhook(event.payload, ctx)
804
+ },
805
+ }
806
+ ```
807
+
808
+ The `publishedAt` value in the event payload is a UTC ISO 8601 string
809
+ (`...Z`). Parse it with `new Date()` or your preferred date library
810
+ before comparing against `Date.now()`.
811
+
812
+ ### Future work
813
+
814
+ Aligning event emission with the scheduled time — so `content.published`
815
+ fires at `publishedAt` rather than at save time — is a planned
816
+ enhancement. It requires a scheduler component (EventBridge Scheduler
817
+ or a DynamoDB TTL-triggered Lambda) and is not yet in scope for the
818
+ current release. Until then, the pattern above is the recommended
819
+ guard for notification plugins.
820
+
821
+ For a full description of `publishedAt` semantics from the operator's
822
+ perspective, see [`docs/scheduled-publishing.md`](https://github.com/heavymoons/ampless/blob/main/docs/scheduled-publishing.md).
823
+
824
+ ---
825
+
826
+ ### CSP nonce (Phase 1 reservation)
827
+
828
+ Content Security Policy (CSP) is a near-mandatory requirement for production
829
+ sites. To avoid breaking every plugin at once when nonce propagation lands,
830
+ ampless reserves the API surface today (Phase 1 no-op) so plugins can opt in
831
+ ahead of time.
832
+
833
+ The 3-layer design:
834
+
835
+ 1. **`ctx.cspNonce?: string`** on `PluginPublicRenderContext` — type-reserved
836
+ on the interface; always `undefined` today. The runtime does not populate
837
+ this field yet; reads resolve to `undefined`. Middleware/SSR nonce threading
838
+ lands with the future CSP RFP.
839
+
840
+ 2. **`descriptor.nonce: 'auto' | string`** — accepted by the type on both
841
+ `inlineScript` and `script` descriptor variants. `'auto'` is the sentinel
842
+ for future runtime stamping; any other string is an explicit literal;
843
+ `undefined` emits no `nonce` attribute (default, backward-compatible).
844
+ Phase 1: the runtime accepts but does not propagate it. Declaring
845
+ `nonce: 'auto'` today is a forward-compatibility hint and does not change
846
+ the rendered HTML.
847
+
848
+ 3. **`'cspReady'` capability** — a name-only declarative badge. Declaring it
849
+ signals intent; future admin UI / sanity checks may surface it. No runtime
850
+ cross-check or enforcement exists in Phase 1.
851
+
852
+ **How to be ready:**
853
+
854
+ ```ts
855
+ // src/index.ts
856
+ definePlugin({
857
+ name: 'my-plugin',
858
+ apiVersion: 1,
859
+ trust_level: 'untrusted',
860
+ capabilities: ['publicHead', 'cspReady'],
861
+ publicHead: () => [{
862
+ type: 'inlineScript',
863
+ id: 'my-snippet',
864
+ body: '...',
865
+ nonce: 'auto', // forward-compat hint; no effect in Phase 1
866
+ }],
867
+ })
868
+ ```
869
+
870
+ For standalone npm-published plugins, also update `package.json#amplessPlugin.capabilities`
871
+ to match — the runtime cross-check warns on disagreement between the static
872
+ manifest and the factory return value:
873
+
874
+ ```json
875
+ {
876
+ "amplessPlugin": {
877
+ "apiVersion": 1,
878
+ "name": "my-plugin",
879
+ "trustLevel": "untrusted",
880
+ "capabilities": ["publicHead", "cspReady"]
881
+ }
882
+ }
883
+ ```
884
+
885
+ **What "cspReady" means:**
886
+
887
+ - Site-level CSP compliance depends on middleware / response headers / other
888
+ inline content the runtime does not control.
889
+ - Once the middleware-driven nonce threading PR lands, plugin-supplied scripts
890
+ that carry `nonce: 'auto'` will become candidates for runtime nonce stamping.
891
+ - `'cspReady'` does **not** appear in `create-ampless plugin --capabilities`
892
+ output — it is a reserved capability and the scaffold excludes it to avoid
893
+ implying active enforcement.
894
+
895
+ ---
896
+
897
+ ## 6b. In-body content renderers: `contentFields` (Phase 7)
898
+
899
+ The `contentFields` capability lets a plugin replace specific fragments
900
+ of a post body — tiptap nodes or single-line markdown URLs — with a
901
+ React subtree it controls. Used by the first-party `@ampless/plugin-youtube`
902
+ and `@ampless/plugin-x-embed` packages to expand `https://youtu.be/...`
903
+ URLs into iframe players and `https://x.com/<handle>/status/...` URLs
904
+ into tweet blockquotes.
905
+
906
+ ### Shape
907
+
908
+ ```ts
909
+ import { definePlugin, type ContentFieldRenderer } from 'ampless'
910
+
911
+ definePlugin({
912
+ // ...
913
+ capabilities: ['contentFields'],
914
+ contentFields: [
915
+ {
916
+ kind: 'tiptap',
917
+ nodeType: 'amplessYoutube',
918
+ render: (node, ctx) => <YouTubeEmbed videoId={String(node.attrs?.videoId)} />,
919
+ },
920
+ {
921
+ kind: 'markdown-url',
922
+ pattern: /^https:\/\/youtu\.be\/([\w-]{11})$/,
923
+ render: ({ match }, ctx) => <YouTubeEmbed videoId={match[1]!} />,
924
+ },
925
+ ],
926
+ })
927
+ ```
928
+
929
+ Each renderer is called server-side by the runtime when it walks a post
930
+ body during `ampless.renderBody(post)`. The return value must be a
931
+ `ReactNode`. The plugin's `PluginPublicRenderContext` (`ctx`) is the
932
+ same context handed to `publicHead` / `publicBodyEnd`, so `ctx.setting<T>(key)`
933
+ works exactly the same way.
934
+
935
+ ### The two kinds
936
+
937
+ - **`tiptap`** — keyed by `nodeType` (a string, e.g. `'amplessYoutube'`).
938
+ The runtime's tiptap walker calls the renderer whenever it encounters
939
+ a node whose `type` matches `nodeType`. Default switch-case rendering
940
+ is bypassed; the plugin owns the subtree.
941
+ - **`markdown-url`** — keyed by an anchored `RegExp` (`^...$`). The
942
+ runtime tokenizes markdown with `marked.lexer` and, for any
943
+ `paragraph` token whose entire content is a single URL (autolink,
944
+ bare URL, or `[text](url)` markdown link), tests the URL against
945
+ each registered pattern. The first match wins; capture groups are
946
+ exposed via `match[1]`, `match[2]`, etc.
947
+
948
+ ### Naming and uniqueness
949
+
950
+ - First-party plugins use the `ampless...` camelCase prefix
951
+ (`amplessYoutube`, `amplessTweet`) so the namespace stays clear of
952
+ community-contributed `nodeType`s.
953
+ - The runtime rejects duplicate `nodeType` / `pattern.source` at
954
+ startup with a thrown error. The first plugin to register a given key
955
+ wins; the second one fails fast. Multi-instance v1 is not supported.
956
+
957
+ ### Markdown URL pattern rules
958
+
959
+ - **Always anchor with `^...$`** so the pattern only matches paragraphs
960
+ whose entire content is a single URL. A pattern without anchors would
961
+ match URLs embedded mid-paragraph and break the surrounding text.
962
+ - The runtime trims leading/trailing whitespace before matching, so
963
+ patterns don't need to account for `\s*` around the URL.
964
+ - Inline `[caption](url)` markdown links are accepted when the link is
965
+ the paragraph's only token. `[caption with link](url)` mixed with
966
+ surrounding text is NOT matched (correct behaviour: the prose
967
+ belongs in the post, not a video embed).
968
+
969
+ ---
970
+
971
+ ## 6c. Page-level scripts: `publicPostScript` (Phase 7)
972
+
973
+ The `publicPostScript` capability lets a plugin emit a `<script>` tag
974
+ that any post on the page needs. The runtime dedupes by stable `id`
975
+ so multiple embeds in one or several posts collapse to one script tag.
976
+ Used by `@ampless/plugin-x-embed` to inject
977
+ `https://platform.twitter.com/widgets.js` once per page that has any
978
+ tweet embed.
979
+
980
+ ### Shape
981
+
982
+ ```ts
983
+ definePlugin({
984
+ capabilities: ['contentFields', 'publicPostScript'],
985
+ publicPostScript(post, ctx) {
986
+ if (!hasTweetIn(post)) return []
987
+ return [
988
+ {
989
+ id: 'amplessTweet:widgets',
990
+ src: 'https://platform.twitter.com/widgets.js',
991
+ async: true,
992
+ },
993
+ ]
994
+ },
995
+ })
996
+ ```
997
+
998
+ ### Theme integration
999
+
1000
+ Themes call `{await ampless.publicPostScriptsForPage(posts)}` after
1001
+ rendering each post body. First-party themes do this automatically in
1002
+ their post detail page and home page (when a featured post is shown):
1003
+
1004
+ ```tsx
1005
+ <div>{await ampless.renderBody(post)}</div>
1006
+ {await ampless.publicPostScriptsForPage([post])}
1007
+ ```
1008
+
1009
+ The runtime:
1010
+
1011
+ 1. Calls `publicPostScript(post, ctx)` for each plugin × post pair.
1012
+ 2. Drops descriptors with empty/non-string `id`, with `src` that fails
1013
+ the http(s) allowlist, or that aren't objects.
1014
+ 3. Dedupes by `id` (first arrival wins).
1015
+ 4. Emits a `<Fragment>` of `<script src={src} async defer />` elements.
1016
+
1017
+ ### CSP considerations
1018
+
1019
+ The runtime does **not** enforce a host allowlist on `src`. CSP is the
1020
+ site engineer's responsibility — add the script host (e.g.
1021
+ `platform.twitter.com`) to `script-src` in `next.config.ts` /
1022
+ middleware. This is documented in each plugin's README.
1023
+
1024
+ ---
1025
+
1026
+ ## 6d. Admin editor extension wiring (Phase 7)
1027
+
1028
+ Plugins that contribute tiptap Node extensions to the admin editor
1029
+ ship a separate client-side entry under the `./editor` subpath.
1030
+ `app/(admin)/admin/_editor-bootstrap.tsx` is **auto-generated** by
1031
+ `npm run update-ampless` — you should not edit it by hand.
1032
+
1033
+ ### For plugin authors
1034
+
1035
+ Declare `editorExports` in `package.json#amplessPlugin`:
1036
+
1037
+ ```jsonc
1038
+ // packages/plugin-youtube/package.json
1039
+ "amplessPlugin": {
1040
+ "apiVersion": 1,
1041
+ "name": "youtube",
1042
+ "trustLevel": "trusted",
1043
+ "capabilities": ["contentFields"],
1044
+ "editorExports": "./editor" // ← subpath where the editor module lives
1045
+ }
1046
+ ```
1047
+
1048
+ Export `editorExtension` as a named symbol from that subpath:
1049
+
1050
+ ```ts
1051
+ // packages/plugin-youtube/src/editor.tsx
1052
+ export const editorExtension = AmplessYoutubeNode // tiptap Node or Extension
1053
+ ```
1054
+
1055
+ The subpath must be declared in `package.json#exports` as well (the
1056
+ codegen validates this — a missing `exports` entry triggers a warning
1057
+ and the plugin is skipped):
1058
+
1059
+ ```jsonc
1060
+ "exports": {
1061
+ ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" },
1062
+ "./editor": { "import": "./dist/editor.js", "types": "./dist/editor.d.ts" }
1063
+ }
1064
+ ```
1065
+
1066
+ ### For plugin users (site engineers)
1067
+
1068
+ 1. `npm i @ampless/plugin-youtube@beta` — add the plugin as a dependency.
1069
+ 2. Register it in `cms.config.ts` for the server-side renderer.
1070
+ 3. `npm run update-ampless` — regenerates `_editor-bootstrap.tsx`
1071
+ automatically from the installed plugin manifests.
1072
+
1073
+ The generated file is committed to your repo and looks like:
1074
+
1075
+ ```tsx
1076
+ // app/(admin)/admin/_editor-bootstrap.tsx (AUTO-GENERATED — do not edit)
1077
+ 'use client'
1078
+ // AUTO-GENERATED by `npm run update-ampless`. Do not edit — your changes
1079
+ // will be overwritten on the next run.
1080
+ import { installAdminEditorExtensions } from '@ampless/admin/editor'
1081
+ import { editorExtension as __ampless_plugin_x_embed_editor } from '@ampless/plugin-x-embed/editor'
1082
+ import { editorExtension as __ampless_plugin_youtube_editor } from '@ampless/plugin-youtube/editor'
1083
+
1084
+ export function EditorBootstrap({ children }: { children: React.ReactNode }) {
1085
+ installAdminEditorExtensions([
1086
+ __ampless_plugin_x_embed_editor,
1087
+ __ampless_plugin_youtube_editor,
1088
+ ])
1089
+ return <>{children}</>
1090
+ }
1091
+ ```
1092
+
1093
+ The file is then wired into the admin layout (already in the template — no
1094
+ extra work required):
1095
+
1096
+ ```tsx
1097
+ // templates/_shared/app/(admin)/admin/layout.tsx
1098
+ import { createAdminLayout } from '@ampless/admin/pages'
1099
+ import { EditorBootstrap } from './_editor-bootstrap'
1100
+ export default createAdminLayout(admin, { editorBootstrap: EditorBootstrap })
1101
+ ```
1102
+
1103
+ `installAdminEditorExtensions` is idempotent and runs at render time
1104
+ inside a client component. The admin's `<TiptapEditor>` spreads the
1105
+ registered list onto its built-in extensions on every render.
1106
+
1107
+ The `_editor-bootstrap.tsx` generated by `update-ampless` wires three
1108
+ install calls — extensions, markdown adapters, and html adapters:
1109
+
1110
+ ```tsx
1111
+ // app/(admin)/admin/_editor-bootstrap.tsx (AUTO-GENERATED — do not edit)
1112
+ 'use client'
1113
+ // AUTO-GENERATED by `npm run update-ampless`. Do not edit ...
1114
+ import { installAdminEditorExtensions, installAdminTiptapNodeMarkdown, installAdminTiptapNodeHtml } from '@ampless/admin/editor'
1115
+ import * as __ampless_plugin_x_embed_editor from '@ampless/plugin-x-embed/editor'
1116
+ import * as __ampless_plugin_youtube_editor from '@ampless/plugin-youtube/editor'
1117
+
1118
+ export function EditorBootstrap({ children }: { children: React.ReactNode }) {
1119
+ installAdminEditorExtensions([
1120
+ __ampless_plugin_x_embed_editor.editorExtension,
1121
+ __ampless_plugin_youtube_editor.editorExtension,
1122
+ ])
1123
+ installAdminTiptapNodeMarkdown([
1124
+ __ampless_plugin_x_embed_editor.tiptapNodeToMarkdown ?? {},
1125
+ __ampless_plugin_youtube_editor.tiptapNodeToMarkdown ?? {},
1126
+ ])
1127
+ installAdminTiptapNodeHtml([
1128
+ __ampless_plugin_x_embed_editor.tiptapNodeToHtml ?? {},
1129
+ __ampless_plugin_youtube_editor.tiptapNodeToHtml ?? {},
1130
+ ])
1131
+ return <>{children}</>
1132
+ }
1133
+ ```
1134
+
1135
+ ### Lossless format-switch adapters (`tiptapNodeToMarkdown` + `tiptapNodeToHtml`)
1136
+
1137
+ When the operator switches the post format in the admin UI (e.g. `tiptap →
1138
+ markdown` or `tiptap → html`), the admin must convert the body content. For
1139
+ standard prose nodes tiptap's built-in renderers handle this, but **atom
1140
+ nodes** (embed blocks like `amplessYoutube`) have no children — they fall
1141
+ through with empty output, silently dropping the embed.
1142
+
1143
+ Two adapters fix both directions:
1144
+
1145
+ | Adapter | Direction | Output |
1146
+ | --------------------- | --------------------------------- | ------ |
1147
+ | `tiptapNodeToMarkdown`| `tiptap → markdown` | bare URL line (e.g. `https://youtu.be/<id>`) |
1148
+ | `tiptapNodeToHtml` | `tiptap → html`, `markdown → html`| canonical placeholder div |
1149
+
1150
+ The three canonical body representations across format-switch are:
1151
+
1152
+ | Format | Canonical form |
1153
+ | -------- | -------------- |
1154
+ | tiptap | `{ type: 'amplessYoutube', attrs: { videoId, start } }` Node |
1155
+ | markdown | bare `https://youtu.be/<id>` URL line |
1156
+ | html | `<div data-ampless-youtube data-video-id="<id>" …>…</div>` |
1157
+
1158
+ The placeholder div is the canonical HTML form for **admin format-switch
1159
+ interop only**: it is what `Node.renderHTML` emits, and what
1160
+ `Node.parseHTML`'s `tag: 'div[data-ampless-*]'` rule restores from, so
1161
+ that switching `tiptap ↔ markdown ↔ html` in the admin preserves embeds
1162
+ losslessly.
1163
+
1164
+ **Public render of `format: 'html'` posts expands the placeholder when
1165
+ the plugin declares `htmlPlaceholder`.** All three public render paths
1166
+ reach the same `contentFields.tiptap` renderer: tiptap posts go through
1167
+ the React walker that consults the `contentFields.tiptap` registry
1168
+ (= real iframe for `amplessYoutube` Nodes); markdown posts go through the
1169
+ markdown walker that consults `contentFields.markdownUrl` (= real iframe
1170
+ for bare URL paragraphs); html posts go through the **public html walker**
1171
+ that consults `contentFields.htmlPlaceholder` (= real iframe for
1172
+ top-level `<div data-ampless-youtube …>` placeholders). The
1173
+ `tiptapNodeToHtml` adapter above only governs the **admin format-switch**
1174
+ interchange form — declaring `htmlPlaceholder` is what makes the public
1175
+ html walker expand that form on render. (See the dedicated
1176
+ `htmlPlaceholder` section below for the contract.) `publicHtmlForPost`
1177
+ still only emits `beforeContent` / `afterContent` slots and does not
1178
+ transform the body.
1179
+
1180
+ A plugin that ships an embed node but does **not** declare
1181
+ `htmlPlaceholder` keeps the old behaviour: the placeholder div is shipped
1182
+ literally on public render of `format: 'html'` posts (the inner canonical
1183
+ URL link is still clickable, so it degrades gracefully). Save as `tiptap`
1184
+ or `markdown` to get the iframe in that case.
1185
+
1186
+ #### Adapter contract
1187
+
1188
+ Both adapters have the same signature:
1189
+
1190
+ ```ts
1191
+ (node: TiptapRenderNode) => string | null
1192
+ ```
1193
+
1194
+ Return a **string** (including empty string `''`) to use your output.
1195
+ Return **`null`** to fall through to the default switch (useful for nodes
1196
+ you don't handle or for degenerate inputs like a missing video id).
1197
+
1198
+ ```ts
1199
+ // packages/plugin-youtube/src/editor.tsx
1200
+ import type { TiptapNodeMarkdownAdapters, TiptapNodeHtmlAdapters } from 'ampless'
1201
+
1202
+ export const tiptapNodeToMarkdown: TiptapNodeMarkdownAdapters = {
1203
+ amplessYoutube: (node) => {
1204
+ const videoId = String(node.attrs?.videoId ?? '').trim()
1205
+ if (!videoId) return null // fall through
1206
+ return `https://youtu.be/${videoId}`
1207
+ },
1208
+ }
1209
+
1210
+ export const tiptapNodeToHtml: TiptapNodeHtmlAdapters = {
1211
+ amplessYoutube: (node) => {
1212
+ const videoId = String(node.attrs?.videoId ?? '').trim()
1213
+ if (!videoId) return null // fall through
1214
+ const attrs = placeholderAttrs(node.attrs ?? {})
1215
+ // Inner content is the canonical URL as a clickable link, not the
1216
+ // editor's visual label `<span>YouTube: id</span>`. Public render of
1217
+ // `format: 'html'` posts shows this content literally; an editor
1218
+ // label would leak. The URL link gracefully degrades — viewers
1219
+ // without iframe expansion still get a clickable link, and it
1220
+ // mirrors the markdown canonical form. parseHTML reads the
1221
+ // `data-video-id` attribute, so the inner content is irrelevant
1222
+ // for round-trip.
1223
+ const url = `https://youtu.be/${videoId}`
1224
+ return `<div ${attrsToHtmlString(attrs)}><a href="${escapeAttr(url)}">${escapeAttr(url)}</a></div>`
1225
+ },
1226
+ }
1227
+ ```
1228
+
1229
+ #### Wiring
1230
+
1231
+ `update-ampless` reads the `tiptapNodeToMarkdown` and `tiptapNodeToHtml`
1232
+ named exports from each plugin's `./editor` module (via namespace import `*
1233
+ as`) and wires them into both installs automatically — **no hand-wiring
1234
+ required**. If your plugin does not export one of the maps, the `?? {}`
1235
+ fallback in the generated file is a no-op.
1236
+
1237
+ #### `markdown → html` 2-hop
1238
+
1239
+ The `markdown → html` direction reuses the `tiptapNodeToHtml` adapter via
1240
+ a 2-hop:
1241
+
1242
+ 1. `markdownToHtml(body)` — marked converts the markdown body to HTML. Bare
1243
+ URL lines become `<p><a href="URL">URL</a></p>`.
1244
+ 2. `generateJSON(html, extensions)` — tiptap parses the HTML. Your plugin's
1245
+ `Node.parseHTML` `tag: 'p'` rule promotes the bare-URL paragraph to the
1246
+ embed Node.
1247
+ 3. `tiptapToHtml(doc, { nodeAdapters })` — the html adapter serialises the
1248
+ embed Node to the placeholder div.
1249
+
1250
+ This means your plugin only needs to export the `tiptap → html` adapter once;
1251
+ the `markdown → html` direction reuses it automatically through tiptap's parse
1252
+ rules. **No duplicate logic is needed.**
1253
+
1254
+ #### Public html walker: `htmlPlaceholder`
1255
+
1256
+ To make `format: 'html'` posts render placeholder divs as real embeds on
1257
+ the public page, add an **`htmlPlaceholder`** declaration to your existing
1258
+ `contentFields` `tiptap` entry. You write **no new renderer** — the walker
1259
+ calls the same `render(node, ctx)` your tiptap entry already uses, so all
1260
+ three formats (tiptap / markdown / html) reach one renderer with no
1261
+ divergence.
1262
+
1263
+ ```ts
1264
+ // packages/plugin-youtube/src/index.tsx
1265
+ contentFields: [
1266
+ {
1267
+ kind: 'tiptap',
1268
+ nodeType: 'amplessYoutube',
1269
+ render: (node) => {
1270
+ const videoId = String(node.attrs?.videoId ?? '')
1271
+ const startRaw = node.attrs?.start
1272
+ const start =
1273
+ typeof startRaw === 'number' && Number.isFinite(startRaw) ? startRaw : undefined
1274
+ return <YouTubeEmbed videoId={videoId} start={start} />
1275
+ },
1276
+ htmlPlaceholder: {
1277
+ // The marker attribute that flags a top-level placeholder div.
1278
+ flagAttr: 'data-ampless-youtube',
1279
+ // Convert the div's HTML attributes into the tiptap node `attrs`
1280
+ // your `render` expects — WITH the right types. The walker is
1281
+ // type-agnostic, so coerce here (e.g. data-start string → number).
1282
+ attrsFromElement: (attribs) => {
1283
+ const start = Number(attribs['data-start'])
1284
+ return {
1285
+ videoId: attribs['data-video-id'] ?? '',
1286
+ start: Number.isFinite(start) ? start : undefined,
1287
+ }
1288
+ },
1289
+ },
1290
+ },
1291
+ // …markdown-url entry unchanged…
1292
+ ]
1293
+ ```
1294
+
1295
+ On public render of a `format: 'html'` post, the runtime parses the body
1296
+ server-side (`htmlparser2`), finds each **top-level** element carrying
1297
+ `flagAttr`, builds a `{ type: nodeType, attrs: attrsFromElement(attribs) }`
1298
+ node, and calls `render(node, ctx)`. Everything else passes through as the
1299
+ **original-string slices** (byte-for-byte; no DOM re-serialisation).
1300
+
1301
+ Constraints worth knowing:
1302
+
1303
+ - **Top-level only.** Placeholder divs nested inside `<blockquote>`,
1304
+ `<li>`, etc. stay literal — consistent with the editor's body-level-only
1305
+ `parseHTML` fallback. Only depth-0 elements expand.
1306
+ - **`flagAttr` is matched case-insensitively.** htmlparser2 lowercases
1307
+ HTML attribute names while parsing, and the runtime lowercases your
1308
+ `flagAttr` at registration, so `<div DATA-AMPLESS-YOUTUBE …>` and
1309
+ `<div data-ampless-youtube …>` both expand. `flagAttr` can be any
1310
+ attribute name — a site-local plugin may use `data-my-embed`; there is
1311
+ no fixed `data-ampless` prefix requirement.
1312
+ - **Graceful degradation.** If `attrsFromElement` or `render` throws, the
1313
+ runtime logs a `console.warn` and falls back to the **raw placeholder
1314
+ slice** (the inner canonical URL link stays clickable) rather than
1315
+ dropping the engineer-authored content.
1316
+ - **Page-level scripts.** If your embed needs a third-party script (e.g.
1317
+ x.com's `widgets.js`), your `publicPostScript` / detection helper must
1318
+ also recognise the placeholder form. `plugin-x-embed`'s `hasTweetIn`
1319
+ matches both `twitter-tweet` and `data-ampless-tweet` in `format: 'html'`
1320
+ bodies so widgets.js is injected for the expanded blockquote to hydrate.
1321
+
1322
+ **Wrapper-boundary change.** A placeholder-free `format: 'html'` post is
1323
+ emitted as a single wrapper `<div>` (the fast path — markup-identical to
1324
+ the previous raw passthrough). A post **with** placeholders becomes
1325
+ **multiple wrapper divs interleaved with the React embed siblings**: the
1326
+ bytes inside each raw chunk are preserved exactly, but the wrapper
1327
+ boundaries shift. Direct-child / adjacent-sibling CSS selectors that
1328
+ assumed one wrapper around the whole body will not match the same way.
1329
+ This is the accepted trade-off for expanding embeds in-place.
1330
+
1331
+ ### Markdown to tiptap restoration
1332
+
1333
+ If an editor Node serializes to markdown as a bare URL line, the reverse
1334
+ direction must be handled by `Node.parseHTML()`, not by paste rules. The
1335
+ admin's `markdown → tiptap` format switch first converts markdown to HTML;
1336
+ GFM autolinks emit a bare URL line as
1337
+ `<p><a href="https://...">https://...</a></p>`, then tiptap parses that
1338
+ HTML into a document. Paste rules only run for user paste / typing events,
1339
+ so they do not fire on this HTML parse path.
1340
+
1341
+ Embed-style Nodes should add a high-priority parse rule for the paragraph
1342
+ shape above, and may also add an `a[href]` rule for other HTML-to-tiptap
1343
+ paths. The paragraph rule should only match when the paragraph contains a
1344
+ single link whose text equals its `href`; that lets the parser replace the
1345
+ whole paragraph with the block embed Node instead of leaving an empty
1346
+ paragraph before the embed. `getAttrs` should validate the URL and return
1347
+ `false` for non-matching links so the normal Link mark remains in place.
1348
+
1349
+ ### Active source and full disable
1350
+
1351
+ **The active source for editor wiring is `package.json#dependencies`**
1352
+ (= `node_modules`), not `cms.config.ts`. This means:
1353
+
1354
+ - Removing a plugin from `cms.config.ts` but keeping it in
1355
+ `package.json` leaves the editor paste rule active. The editor can
1356
+ still insert the node type into the tiptap document, but the public
1357
+ renderer won't render it (inconsistent state — avoid).
1358
+ - To fully disable a plugin's editor extension: remove it from
1359
+ `cms.config.ts` **and** run `npm uninstall @ampless/plugin-...`,
1360
+ then `npm run update-ampless` to regenerate the bootstrap file.
1361
+
1362
+ ### Editor preview pipeline
1363
+
1364
+ The admin's edit / new post forms render the preview pane in an
1365
+ `<iframe sandbox="allow-scripts allow-same-origin">` whose `srcDoc` is the HTML returned
1366
+ by the template's preview Route Handler. The template scaffold ships
1367
+ the handler at `app/(admin)/admin/preview/route.tsx`:
1368
+
1369
+ ```tsx
1370
+ // templates/_shared/app/(admin)/admin/preview/route.tsx
1371
+ import type { Post } from 'ampless'
1372
+ import { admin } from '@/lib/admin'
1373
+
1374
+ export async function POST(req: Request): Promise<Response> {
1375
+ const session = await admin.getServerSession()
1376
+ if (!admin.isEditor(session)) {
1377
+ return new Response('Forbidden', { status: 403 })
1378
+ }
1379
+ let draft: Post
1380
+ try {
1381
+ draft = (await req.json()) as Post
1382
+ } catch {
1383
+ return new Response('Bad Request', { status: 400 })
1384
+ }
1385
+ const ampless = await admin.getAmpless()
1386
+ const node = (
1387
+ <>
1388
+ {await ampless.renderBody(draft)}
1389
+ {await ampless.publicPostScriptsForPage([draft])}
1390
+ </>
1391
+ )
1392
+ // Dynamic import: see "Why a Route Handler" below.
1393
+ const { renderToStaticMarkup } = await import('react-dom/server')
1394
+ return new Response(renderToStaticMarkup(node), {
1395
+ headers: {
1396
+ 'Content-Type': 'text/html; charset=utf-8',
1397
+ 'Cache-Control': 'no-store',
1398
+ },
1399
+ })
1400
+ }
1401
+ ```
1402
+
1403
+ `<PostForm>` / `<PostHistoryPanel>` POST the draft to `/admin/preview`
1404
+ out of the box — no extra wiring at the call site:
1405
+
1406
+ ```tsx
1407
+ // templates/_shared/app/(admin)/admin/posts/[postId]/page.tsx
1408
+ import { admin } from '@/lib/admin'
1409
+ import { createEditPostPage } from '@ampless/admin/pages'
1410
+
1411
+ export default createEditPostPage(admin)
1412
+ ```
1413
+
1414
+ If the admin is mounted at a non-default path (Next.js `basePath` or
1415
+ a custom prefix), override the endpoint via the page factory's
1416
+ `previewEndpoint` option:
1417
+
1418
+ ```tsx
1419
+ export default createEditPostPage(admin, { previewEndpoint: '/cms/admin/preview' })
1420
+ ```
1421
+
1422
+ Why a Route Handler rather than a Server Action: putting
1423
+ `react-dom/server`-driven rendering inside a `'use server'` module
1424
+ makes Next.js 15+ refuse to compile the edit-post page, because the
1425
+ build traces the import graph from Client Components through Server
1426
+ Action modules and any reach to `react-dom/server` along that path
1427
+ trips the "You're importing a component that imports
1428
+ react-dom/server" check. A Route Handler decouples the rendering
1429
+ from that graph entirely — `<PostForm>` fetches a plain HTTP
1430
+ endpoint and the bundler never walks from the form into here. The
1431
+ handler's explicit `admin.isEditor()` check also defends against
1432
+ accidental exposure if the `(admin)` route-group gate is ever
1433
+ misconfigured. The `react-dom/server` import itself is dynamic
1434
+ because Next.js 16's Turbopack flags any top-level static import of
1435
+ `react-dom/server` reached from the app router build, Route Handlers
1436
+ included; deferring it to request time keeps the module outside the
1437
+ build-time import-graph walker while still loading it from the same
1438
+ Node.js subpath at runtime.
1439
+
1440
+ **Preview iframe sandbox — v1 trust boundary expansion:** The iframe uses
1441
+ `sandbox="allow-scripts allow-same-origin"`. With `srcDoc`, this gives
1442
+ the iframe the admin's origin, which 3rd-party embed widgets (YouTube SDK,
1443
+ x.com `widgets.js`) require — they refuse to initialise in an opaque-origin
1444
+ (`allow-scripts`-only) iframe because they need access to non-HttpOnly
1445
+ storage / cache and real-origin requests. Same-origin also gives the
1446
+ preview script access to the admin's auth state / non-HttpOnly storage /
1447
+ DOM and lets it issue authenticated same-origin XHR / fetch.
1448
+
1449
+ This is an explicit v1 design decision, not a no-op sandbox relax:
1450
+ **ampless v1 treats admin preview content / plugin script as trusted**.
1451
+ The engineer audits plugins before npm-installing them (customization-based
1452
+ CMS model), and body content is produced by trusted editors of this site.
1453
+ `<PostHistoryPanel>` can also surface a past revision authored by a
1454
+ different editor (revision author ≠ preview viewer) — v1 explicitly puts
1455
+ both inside the trust ring. The safer alternative — separate-origin preview
1456
+ route + CSP / COEP / COOP — is parked for v2.0+ if/when a real plugin
1457
+ marketplace lands.
1458
+
1459
+ ---
1460
+
720
1461
  ## 7. Async event hooks
721
1462
 
722
1463
  `hooks` runs inside the trust_level-matched processor Lambda when an
723
- event arrives via SQS. The runtime context (`ctx`) carries:
1464
+ event arrives via SQS.
1465
+
1466
+ ### Return value reservation
1467
+
1468
+ `PluginEventHandler` returns `Promise<void | PluginHookResult>`. The
1469
+ runtime currently ignores the return value entirely — existing
1470
+ plugins returning `Promise<void>` keep working without migration.
1471
+ `PluginHookResult` is reserved for a future directive (likely first:
1472
+ `metrics?: Record<string, number>` for observability emission);
1473
+ declaring it today is a forward-compatibility hint. Note: rewrite
1474
+ or cancel directives are NOT enabled by this widening alone — they
1475
+ require additional `before:*` event support and payload extensions
1476
+ in separate PRs.
1477
+
1478
+ `PluginHookResult` carries a private `__amplessPluginHookResult`
1479
+ marker so that the union does not silently accept unrelated promise
1480
+ types (`Promise<string>` / `Promise<number>` etc.) — plugin authors
1481
+ do not need to set this field.
1482
+
1483
+ ### Runtime context
1484
+
1485
+ The runtime context (`ctx`) carries:
724
1486
 
725
1487
  ```ts
726
1488
  interface PluginRuntimeContext {
@@ -924,71 +1686,82 @@ etc. — but completely wrong for a webhook signing secret.
924
1686
 
925
1687
  - Stored in the `PluginSecret` DynamoDB table (separate from KvStore).
926
1688
  Admin/editor Cognito users have **no direct AppSync access** to this
927
- table — all writes go through the `setPluginSecret` mutation backed
928
- by the `plugin-secret-handler` Lambda.
1689
+ table — all writes go through the `setPluginSecret` mutation, which
1690
+ is backed by the `plugin-secret-handler` Lambda.
929
1691
  - Values are **AES-256-GCM encrypted** before reaching DynamoDB.
930
1692
  The admin browser sends the plaintext to the Lambda via AppSync
931
- (TLS); the Lambda validates, reads the 32-byte encryption key from
932
- `process.env.PLUGIN_SECRET_ENCRYPTION_KEY` (injected by CDK from
933
- `amplify/secrets/encryption-key.ts`), and writes only the ciphertext.
934
- The plaintext never rests in DynamoDB and never flows back to the browser.
1693
+ (TLS in transit); the Lambda validates it, reads the 32-byte
1694
+ encryption key from `process.env.PLUGIN_SECRET_ENCRYPTION_KEY`
1695
+ (injected by CDK from `amplify/secrets/encryption-key.ts`), encrypts
1696
+ the value, and writes only the ciphertext to DDB. The plaintext
1697
+ never rests in DynamoDB and never flows back to the browser.
935
1698
  - **Threat model (Phase 6a v2.2)**:
936
1699
 
937
1700
  | Threat | Status |
938
1701
  |---|---|
939
1702
  | AWS Console operator browsing PluginSecret table | ✓ defeated — ciphertext only, no key in DDB |
940
- | Source repo / deploy artifact access | ⚠ NOT defeated — key is in `amplify/secrets/encryption-key.ts`. Private repo + restricted artifact access expected. |
1703
+ | Source repo / deploy artifact access | ⚠ NOT defeated — key is in `amplify/secrets/encryption-key.ts`. For public repos, keep the key out of source control (for example with `npx create-ampless@beta setup-encryption-key --gitignore`) and restrict deploy artifact access. |
941
1704
  | Malicious trusted plugin in same Lambda | ✗ NOT defeated — `process.env.PLUGIN_SECRET_ENCRYPTION_KEY` reachable from plugin code. True isolation = per-plugin Lambda (privileged tier, roadmap). |
942
1705
  | S3 mirror leak | ✓ defeated — PluginSecret table never mirrored. |
943
1706
 
944
1707
  - The trusted-processor Lambda decrypts with `node:crypto` on read.
945
- `ctx.secret<T>(key)` returns the plaintext string, never ciphertext.
1708
+ `ctx.secret<T>(key)` returns the plaintext string, never the
1709
+ ciphertext.
946
1710
  - Never queried by the site-settings mirror path.
947
1711
  - Never passed to any public-render surface
948
- (`publicHead`, `publicBodyEnd`, `publicBodyForPost`,
949
- `publicHtmlForPost`) — those surfaces only see `ctx.setting()`.
950
1712
  - **Field-manifest validation scope**: the admin client validates
951
1713
  `pattern` / `maxLength` / `required` for UX feedback, but the
952
- Lambda only enforces a generic 10,000-character hard cap +
953
- safe-character sanitizer. An admin/editor calling the AppSync
954
- mutation directly can bypass field-level constraints — by design,
955
- since admin/editor are trusted operators authorised to set
956
- secrets. The manifest checks are UX guidance, not a security
957
- boundary.
958
-
959
- ### Requirements
960
-
961
- `settings.secret` requires:
962
-
963
- 1. `trust_level: 'trusted'` — untrusted Lambdas have no DDB read
964
- access to the PluginSecret table. `definePlugin()` throws if you
965
- declare `settings.secret` with any other trust level.
966
- 2. `'secretSettings'` in `capabilities` required so admin UI and
967
- future allow-lists can gate the capability. Omitting it produces
968
- a console warning (same pattern as `'schema'` vs `publicBodyForPost`).
1714
+ Lambda only enforces a generic 10,000-character hard cap + safe-
1715
+ character sanitizer. An admin/editor calling the AppSync mutation
1716
+ directly can bypass field-level constraints — by design, since
1717
+ admin/editor are trusted operators authorised to set secrets.
1718
+ The manifest checks are UX guidance, not a security boundary.
1719
+ (`publicHead`, `publicBodyEnd`, `publicBodyForPost`,
1720
+ `publicHtmlForPost`) — those surfaces only see `ctx.setting()`.
1721
+
1722
+ ### Requirements and `definePlugin()` behaviour
1723
+
1724
+ `settings.secret` has four observable behaviours at `definePlugin()` time (this is the v1 first-party organization hard gate for secret access; see [plugin.ts:1004-1019](https://github.com/heavymoons/ampless/blob/main/packages/ampless/src/plugin.ts#L1004-L1019)):
1725
+
1726
+ 1. **`settings.secret` non-empty + `trust_level !== 'trusted'`** → `definePlugin()` **throws**. Untrusted and privileged Lambdas have no IAM read access to the `PluginSecret` table; the trusted Lambda's IAM permission is required.
1727
+ 2. **`settings.secret` non-empty + `capabilities` declared + `'secretSettings'` missing from `capabilities`** → **soft mismatch warning**. Matches the existing capability-mismatch pattern for `'schema'` / `'publicHtmlForPost'`.
1728
+ 3. **`settings.secret` non-empty + `capabilities` undefined** (legacy plugin without a `capabilities` array) **no warning**. The mismatch check is skipped when `capabilities` is `undefined`, for backward compatibility.
1729
+ 4. **`capabilities: ['secretSettings']` declared with no `settings.secret` field** **no-op**. Neither warning nor throw.
1730
+
1731
+ To use `settings.secret`, you also need:
1732
+ 1. `trust_level: 'trusted'` (requirement #1 above; `definePlugin()` throws otherwise).
1733
+ 2. `'secretSettings'` in `capabilities` (omitting it when `capabilities` is defined produces a console warning).
969
1734
  3. **One-time key setup** — run from your project root:
970
1735
  ```sh
971
- npx create-ampless setup-encryption-key
1736
+ npx create-ampless@beta setup-encryption-key
972
1737
  ```
973
- Generates a cryptographically random 32-byte key and writes it to
974
- `amplify/secrets/encryption-key.ts`. No AWS credentials required.
975
- Then import it in `amplify/backend.ts` and pass it to
1738
+ This generates a cryptographically random 32-byte key and writes
1739
+ it to `amplify/secrets/encryption-key.ts`. No AWS credentials
1740
+ required this is a local file operation only.
1741
+
1742
+ Then import the constant in `amplify/backend.ts` and pass it to
976
1743
  `defineAmplessBackend({ pluginSecretEncryptionKey })`. Redeploy (or
977
- restart the sandbox) to inject the key into Lambda env vars.
978
- For public repos, add `--gitignore` to exclude from version control.
1744
+ restart the sandbox) to inject the key into the Lambda env vars.
1745
+
1746
+ For public repos, pass `--gitignore` to exclude the key file from
1747
+ version control and distribute the key separately.
979
1748
 
980
1749
  ### Dual-write integrity
981
1750
 
982
- The `setPluginSecret` and `clearPluginSecret` operations each write to
983
- **two DynamoDB tables** in sequence. If the second write fails, the
984
- tables are left in a predictable state:
1751
+ The `setPluginSecret` and `clearPluginSecret` operations each write
1752
+ to **two DynamoDB tables** in sequence: `PluginSecret` (ciphertext)
1753
+ and `PluginSecretIndicator` (existence timestamp). If the second
1754
+ write fails, the tables are left in a predictable, documented state:
985
1755
 
986
- | Failure | `PluginSecret` | `PluginSecretIndicator` | `ctx.secret()` | `hasPluginSecret()` |
1756
+ | Failure point | `PluginSecret` | `PluginSecretIndicator` | `ctx.secret()` | `hasPluginSecret()` |
987
1757
  |---|---|---|---|---|
988
1758
  | **set**: indicator PutItem fails | ciphertext present | absent | returns plaintext ✓ | `false` (UI: "not saved") |
989
- | **clear**: indicator DeleteItem fails | absent | stale | `undefined` ✓ | `true` (UI: "saved") |
1759
+ | **clear**: indicator DeleteItem fails | absent | stale (old timestamp) | `undefined` ✓ | `true` (UI: "saved") |
990
1760
 
991
- The clear-path failure is the "safe side": the secret stops firing.
1761
+ The clear-path failure is the "safe side": the secret stops firing even
1762
+ though the UI briefly shows it as saved. The set-path failure is a minor
1763
+ UI inaccuracy: the secret is functional but the existence indicator is
1764
+ absent until a retry succeeds.
992
1765
 
993
1766
  ### Declaring secret fields
994
1767
 
@@ -1020,9 +1793,16 @@ export default function webhookPlugin(opts?: { signingSecret?: string }) {
1020
1793
  },
1021
1794
  hooks: {
1022
1795
  async 'content.published'(event, ctx) {
1796
+ // ctx.secret() reads from PluginSecret DDB table.
1797
+ // Returns undefined when no value has been saved by admin yet.
1023
1798
  const storedSecret = await ctx.secret<string>('signingSecret')
1799
+
1800
+ // Closure-private fallback: use the constructor argument when
1801
+ // the admin has not saved a value yet. This preserves backward
1802
+ // compatibility with sites that pass the secret at install time.
1024
1803
  const secret = storedSecret ?? constructorSecret
1025
- if (!secret) return
1804
+ if (!secret) return // no secret → skip signing
1805
+
1026
1806
  // ... use secret to sign and POST
1027
1807
  },
1028
1808
  },
@@ -1034,25 +1814,306 @@ export default function webhookPlugin(opts?: { signingSecret?: string }) {
1034
1814
 
1035
1815
  The type `PluginSecretField` is defined as `Omit<PluginTextField,
1036
1816
  'default'> | Omit<PluginTextareaField, 'default'>` — the `default`
1037
- property is **removed at the type level**. Use a closure-private
1038
- fallback variable instead; never put credentials in the manifest.
1817
+ property is **removed at the type level** and TypeScript will error
1818
+ if you try to add one.
1819
+
1820
+ This is intentional: `default` values propagate into admin UI form
1821
+ props (visible in the browser), static manifests cross-checked by
1822
+ the runtime, and JS bundles. For a credential, these are all leak
1823
+ paths.
1824
+
1825
+ If you have a fallback value (e.g. the constructor argument), keep
1826
+ it as a **closure-private variable** inside the plugin factory
1827
+ function — never in the manifest:
1828
+
1829
+ ```ts
1830
+ // ✓ correct — closure-private, never in the manifest
1831
+ const constructorSecret = opts?.signingSecret
1832
+
1833
+ // ✗ wrong — TypeScript error, would also leak to browser
1834
+ settings: {
1835
+ secret: [{
1836
+ type: 'text',
1837
+ key: 'signingSecret',
1838
+ label: 'Secret',
1839
+ default: opts?.signingSecret, // ← TS compile error
1840
+ }],
1841
+ }
1842
+ ```
1039
1843
 
1040
1844
  ### Reading secrets: `ctx.secret<T>(key)`
1041
1845
 
1042
- `ctx.secret<T>(key)` is only available in trusted hook handlers.
1043
- Returns `undefined` when no value has been saved by admin. Returns
1044
- the **decrypted plaintext** — never the ciphertext blob.
1045
- Results are per-invocation cached (plaintext, not ciphertext).
1046
- The encryption key is decoded from the Lambda env var at cold-start
1047
- (no extra DDB fetch).
1048
- Cache keys are namespaced by `${instanceId ?? name}:${fieldKey}`.
1846
+ `ctx.secret<T>(key)` is only available in trusted hook handlers
1847
+ (injected by `processor-trusted.ts`). The signature is:
1848
+
1849
+ ```ts
1850
+ ctx.secret<T = string>(key: string): Promise<T | undefined>
1851
+ ```
1852
+
1853
+ - Returns `undefined` when no value has been saved by admin yet.
1854
+ - The generic `T` is a convenience cast (same as `ctx.setting<T>()`).
1855
+ Values are always stored as strings; `T` defaults to `string`.
1856
+ - Results are per-invocation cached. Calling `ctx.secret('key')`
1857
+ twice in the same hook batch costs one DDB round-trip (and one
1858
+ decrypt). The encryption key is decoded from the Lambda env var
1859
+ at cold-start (no extra DDB fetch).
1860
+ - Cache keys are namespaced: `${instanceId ?? name}:${fieldKey}`.
1861
+ Two plugin instances both declaring `'signingSecret'` never get
1862
+ each other's values.
1863
+ - The cached value is the **decrypted plaintext** — not the
1864
+ ciphertext. There is no redundant decrypt on repeated calls.
1049
1865
 
1050
1866
  ### Admin UI
1051
1867
 
1052
1868
  When `settings.secret` is declared, the admin plugin settings page
1053
1869
  renders a **Secret settings** section below the public fields.
1054
- Admins see `••••••••` for stored values and can Replace or Clear
1055
- without deploying. Rotation takes effect within ~5–10 seconds.
1870
+ Each secret field shows:
1871
+
1872
+ - **Unset**: a plain text input + Save button.
1873
+ - **Stored**: a masked placeholder `••••••••` + Replace + Clear
1874
+ buttons. The actual value is never fetched or displayed.
1875
+ - **Editing**: after clicking Replace — new text input + Save + Cancel.
1876
+
1877
+ Admins can rotate a secret at any time without redeploying. The
1878
+ change takes effect on the next trusted-Lambda invocation (within
1879
+ ~5–10 seconds of saving).
1880
+
1881
+ ### Testing hooks that use `ctx.secret`
1882
+
1883
+ Mock `ctx.secret` alongside the other context methods:
1884
+
1885
+ ```ts
1886
+ import { describe, it, expect, vi } from 'vitest'
1887
+ import webhookPlugin from './index.js'
1888
+ import type { TrustedPluginRuntimeContext } from 'ampless'
1889
+
1890
+ function makeCtx(secrets: Record<string, string> = {}): TrustedPluginRuntimeContext {
1891
+ return {
1892
+ site: { name: 'Test', url: 'https://example.com' },
1893
+ listPublishedPosts: vi.fn().mockResolvedValue([]),
1894
+ writePublicAsset: vi.fn().mockResolvedValue(''),
1895
+ secret: vi.fn().mockImplementation(async (key: string) => secrets[key]),
1896
+ }
1897
+ }
1898
+
1899
+ describe('webhookPlugin signing', () => {
1900
+ it('uses admin-stored secret when available', async () => {
1901
+ const plugin = webhookPlugin()
1902
+ const ctx = makeCtx({ signingSecret: 'stored-secret' })
1903
+ await plugin.hooks?.['content.published']?.({ type: 'content.published', payload: {} as never }, ctx)
1904
+ // assert that the request was signed with 'stored-secret'
1905
+ })
1906
+
1907
+ it('falls back to constructor secret when admin has not saved one', async () => {
1908
+ const plugin = webhookPlugin({ signingSecret: 'fallback-secret' })
1909
+ const ctx = makeCtx({}) // no stored secret
1910
+ await plugin.hooks?.['content.published']?.({ type: 'content.published', payload: {} as never }, ctx)
1911
+ // assert that the request was signed with 'fallback-secret'
1912
+ })
1913
+ })
1914
+ ```
1915
+
1916
+ ---
1917
+
1918
+ ## 9b. Where your plugin's data lives
1919
+
1920
+ Plugin-owned data may live in the five storage areas listed below; the
1921
+ **current write paths differ by area** and fall into three families:
1922
+
1923
+ - **KvStore** — admin/editor write through AppSync. Plugin hooks have no
1924
+ KvStore write helper today.
1925
+ - **PluginSecret + PluginSecretIndicator** — written by the
1926
+ `plugin-secret-handler` Lambda, which is invoked by admin/editor through
1927
+ the `setPluginSecret` / `clearPluginSecret` AppSync mutations. The
1928
+ trusted processor reads `PluginSecret` via `ctx.secret<T>()` but does
1929
+ NOT write to either secret table.
1930
+ - **S3 `public/plugins/{instanceId ?? name}/*`** — written by the trusted
1931
+ Lambda's hook context (`ctx.writePublicAsset(...)`). This is the only
1932
+ area a plugin hook writes to directly today.
1933
+
1934
+ All other areas — the `Post`, `Page`, `Media`, and `PostTag` DynamoDB
1935
+ tables, the `public/site-settings.json` S3 mirror, and any other plugin's
1936
+ namespace — are off-limits. The runtime does not enforce this today; it is
1937
+ a contract enforced by trust (and future IAM hardening).
1938
+
1939
+ | Area | Path / identifier | Access level | Phase |
1940
+ |---|---|---|---|
1941
+ | KvStore (admin settings) | DynamoDB `pk='siteconfig'`, `sk='plugins.<instanceId>.<fieldKey>'` | admin/editor via AppSync; plugin hook write helper is not provided today | Phase 2 |
1942
+ | KvStore (runtime state/cache) | DynamoDB `pk='pluginstate:<plugin>:...'` with optional TTL | admin/editor via AppSync; plugin hook write helper is not provided today | Current |
1943
+ | PluginSecret | DynamoDB `PluginSecret` table, `sk='plugins.<instanceId>.<fieldKey>'` | `trusted` only (IAM-only AppSync auth) | Phase 6a |
1944
+ | PluginSecretIndicator | DynamoDB `PluginSecretIndicator` table, `sk='plugins.<instanceId>.<fieldKey>'` | `trusted` + admin/editor (read indicator) | Phase 6a |
1945
+ | S3 plugin assets | `public/plugins/{instanceId ?? name}/*` | `trusted` only (`writePublicAsset`) | Phase 3 |
1946
+
1947
+ **Cleanup is not automatic.** Removing a plugin from `cms.config.ts` leaves
1948
+ orphan data in all five areas. Manual operator cleanup is required until the
1949
+ future lifecycle-dispatch PR ships the invocation mechanism for the `uninstall`
1950
+ hook (see §9c below).
1951
+
1952
+ **Custom DynamoDB tables.** If your plugin provisions its own DynamoDB table
1953
+ outside the ampless schema, lifecycle management (including cleanup on uninstall)
1954
+ is your responsibility. ampless has no visibility into external tables and the
1955
+ future `uninstall` cleanup grants cover only the five areas above.
1956
+
1957
+ See [`docs/architecture/08-plugin-architecture.md`](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.md#plugin-owned-data-areas)
1958
+ for the full rationale and the IAM grant design.
1959
+
1960
+ ---
1961
+
1962
+ ## 9c. `uninstall` hook (Phase 1 reservation)
1963
+
1964
+ `AmplessPlugin.uninstall` is a **Phase 1 type reservation** — the runtime does
1965
+ not call it today. Its purpose is to lock in the hook name and signature before
1966
+ any plugin code ships, so the future lifecycle-dispatch PR can wire the
1967
+ invocation without renaming or reshaping anything.
1968
+
1969
+ **Phase 1 scope**: only the hook name and signature are reserved. The `ctx`
1970
+ does **not** yet carry cleanup helpers (`deletePublicAsset` /
1971
+ `deletePluginSetting` / `deletePluginSecret`) — writing
1972
+ `await ctx.deletePublicAsset(...)` today is a TypeScript error. When those
1973
+ helpers land (in the lifecycle-dispatch PR), they are added to
1974
+ `PluginUninstallContext` additively, with no breaking change to plugins that
1975
+ declared an empty body in advance.
1976
+
1977
+ **Recommended declaration today** — an empty body:
1978
+
1979
+ ```ts
1980
+ // Example: a trusted plugin that writes assets and stores secrets
1981
+ definePlugin({
1982
+ name: 'my-trusted-plugin',
1983
+ apiVersion: 1,
1984
+ trust_level: 'trusted',
1985
+ capabilities: ['eventHooks', 'writePublicAsset', 'secretSettings'],
1986
+ hooks: { 'content.published': async (_evt, ctx) => { /* ... */ } },
1987
+ uninstall: async (_ctx) => {
1988
+ // Phase 1 reservation: the runtime does not invoke this hook
1989
+ // today, AND `ctx` does not yet carry cleanup helpers
1990
+ // (`deletePublicAsset` / `deletePluginSetting` /
1991
+ // `deletePluginSecret`). Declaring an empty body is the
1992
+ // recommended forward-compat shape — when the future
1993
+ // lifecycle-dispatch PR ships, the helpers land on
1994
+ // `PluginUninstallContext` additively, and you fill in the
1995
+ // body THEN. Plugins that shipped the empty declaration
1996
+ // today do not need to re-publish for the signature change,
1997
+ // but a re-publish is required to add the actual cleanup
1998
+ // body.
1999
+ },
2000
+ })
2001
+ ```
2002
+
2003
+ **Idempotency.** When the lifecycle-dispatch PR ships, the `uninstall` hook
2004
+ runs in a trusted-Lambda IAM context. The SQS delivery is at-least-once, so
2005
+ your cleanup body may run more than once — design it to be safe to retry
2006
+ (e.g. `deleteObject` is idempotent on S3; a conditional `delete` on DDB is
2007
+ safe if the key is already gone).
2008
+
2009
+ ---
2010
+
2011
+ ## 9d. When you change settings shape (Phase 1 reservation)
2012
+
2013
+ ### What happens today: `public` and `secret` travel through different paths
2014
+
2015
+ Shape changes are absorbed silently by today's runtime, but the actual
2016
+ behaviour differs between `settings.public` and `settings.secret` because
2017
+ they use entirely different write/read paths.
2018
+
2019
+ #### `settings.public` (lenient resolver via `resolvePluginSettings`)
2020
+
2021
+ `resolvePluginSettings` ([packages/ampless/src/plugin-settings.ts](packages/ampless/src/plugin-settings.ts))
2022
+ iterates `manifest.public` and falls back to `field.default` per field. The
2023
+ resolver never looks at `manifest.secret`.
2024
+
2025
+ | Change | Behaviour today (public fields) |
2026
+ |---|---|
2027
+ | **Field added** | New field resolves via `manifest.default` — stored value is absent, default takes over. |
2028
+ | **Field deleted** | Orphan row remains in KvStore. `resolvePluginSettings` silently skips keys that are not in the current manifest. |
2029
+ | **Field renamed** (`endpoint` → `url`) | Treated as deletion + addition: old value is unreachable (orphan), new field resolves via `default`. |
2030
+ | **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`). |
2031
+
2032
+ #### `settings.secret` (admin UI + `PluginSecret` + `ctx.secret()`, no lenient resolver)
2033
+
2034
+ Secret fields are never read by `resolvePluginSettings`. They travel a
2035
+ separate path:
2036
+
2037
+ - The admin UI writes individual values through the `setPluginSecret`
2038
+ AppSync mutation, which the `plugin-secret-handler` Lambda encrypts and
2039
+ stores in the `PluginSecret` DynamoDB table.
2040
+ - Trusted hooks read them individually by key via `ctx.secret<T>(key)`,
2041
+ which goes directly to `PluginSecret` and decrypts.
2042
+ - The `PluginSecretField` type forbids `default` — there is no
2043
+ manifest-level fallback for secret values.
2044
+
2045
+ | Change | Behaviour today (secret fields) |
2046
+ |---|---|
2047
+ | **Field added** | New field appears in the admin UI. Until an admin sets a value, `ctx.secret<T>(key)` returns `undefined`. |
2048
+ | **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. |
2049
+ | **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. |
2050
+ | **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. |
2051
+
2052
+ No error or warning is produced in any of these cases. Plugin authors must
2053
+ inspect their stored values manually if they need to verify behaviour
2054
+ after a shape change.
2055
+
2056
+ ### The `version` reservation
2057
+
2058
+ `PluginSettingsManifest.version?: number` is a **Phase 1 type
2059
+ reservation** — the runtime does NOT read it today. Declaring it has no
2060
+ effect on the lenient resolver above.
2061
+
2062
+ The reservation exists so that a future migration PR may persist the
2063
+ active manifest version somewhere alongside stored values and compare it to
2064
+ `manifest.version` at resolve time to detect mismatch. The exact mismatch
2065
+ response (warn / skip / migrate in-place / trigger an admin-driven flow) is
2066
+ design territory for that future PR.
2067
+
2068
+ **What declaring `version` today does NOT promise:**
2069
+
2070
+ - It does NOT trigger any migration body.
2071
+ - It does NOT cause the runtime to re-validate or re-default stored values.
2072
+ - It does NOT reserve a `migrate` hook signature — that is a separate future
2073
+ design.
2074
+
2075
+ **What declaring `version` today does do:**
2076
+
2077
+ - It positions the plugin to be picked up by the future migration detection
2078
+ path, without requiring a re-publish just to add the `version` field once
2079
+ that PR ships.
2080
+ - It communicates intent to future maintainers that this manifest has a
2081
+ versioned shape.
2082
+
2083
+ ### Recommended pattern
2084
+
2085
+ | Scenario | Recommendation |
2086
+ |---|---|
2087
+ | Additive change only (new optional field, default provided) | No `version` bump needed. Lenient resolver handles it. |
2088
+ | Non-additive change (rename, incompatible type change, semantic shift) | Bump `version` by 1. |
2089
+ | First time adding `version` to an existing manifest | Start at `version: 1`. |
2090
+
2091
+ **Use positive integers, starting at 1.** Do NOT use `0`, negative numbers,
2092
+ or floats — the `number` type accepts them but the future migration PR may
2093
+ reserve special semantics for `0` / undefined (legacy / pre-v1 conflation).
2094
+
2095
+ ### Code example
2096
+
2097
+ ```ts
2098
+ definePlugin({
2099
+ name: 'my-plugin',
2100
+ apiVersion: 1,
2101
+ trust_level: 'untrusted',
2102
+ capabilities: ['adminSettings'],
2103
+ settings: {
2104
+ version: 2, // ← Phase 1 reservation. Today runtime ignores.
2105
+ public: [
2106
+ { type: 'url', key: 'webhookUrl', label: 'Webhook URL', required: true },
2107
+ ],
2108
+ },
2109
+ })
2110
+ ```
2111
+
2112
+ When a future migration PR ships, plugins that have already declared
2113
+ `version` will be on the detection path automatically. Plugins that want to
2114
+ provide an actual migration body will need to re-publish after that PR ships
2115
+ to add the body. Plugins that omit `version` entirely continue with the
2116
+ current lenient-resolver behaviour — no change.
1056
2117
 
1057
2118
  ---
1058
2119
 
@@ -1168,10 +2229,15 @@ a normal npm package:
1168
2229
  monorepo.
1169
2230
  - **Entry**: ESM only, exports default (the factory) and the
1170
2231
  config interface (for typed args in user `cms.config.ts`).
1171
- - **`apiVersion`**: bump only when the contract changes. Bump
1172
- major when an existing field's type changes; bump minor when you
1173
- add a new field (existing installs keep working).
1174
- - **Dist-tag**: `@alpha` while ampless itself is in alpha. The
2232
+ - **`apiVersion`**: declare `1` today — it is the only valid
2233
+ value, and the literal type rejects others at compile time.
2234
+ `apiVersion` is the breaking-change marker on the plugin
2235
+ contract, not a semver-style channel: additive changes (new
2236
+ optional fields, new reserved capabilities) stay within
2237
+ `apiVersion: 1` and do NOT require a bump. See the [apiVersion
2238
+ bump policy](https://github.com/heavymoons/ampless/blob/main/docs/architecture/08-plugin-architecture.md#apiversion-bump-policy)
2239
+ in the architecture doc for the full criteria.
2240
+ - **Dist-tag**: `@beta` while ampless itself is in beta. The
1175
2241
  `@latest` tag stays reserved until ampless v1.0.
1176
2242
 
1177
2243
  Worked examples to crib from:
@@ -1181,7 +2247,7 @@ Worked examples to crib from:
1181
2247
  - [`packages/plugin-plausible`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-plausible) — single `<script>` descriptor with `data-*` attrs and a required URL field (self-hosted Plausible override).
1182
2248
  - [`packages/plugin-rss`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-rss) — trusted, async event hooks + `writePublicAsset`.
1183
2249
  - [`packages/plugin-seo`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-seo) — `metadata()` + `siteMetadata()`.
1184
- - [`packages/plugin-webhook`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-webhook) — untrusted hook with outbound HTTP.
2250
+ - [`packages/plugin-webhook`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-webhook) — trusted hook with outbound HTTP + `secretSettings` (admin-managed signing secret, Phase 6a).
1185
2251
  - [`packages/plugin-og-image`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-og-image) — `ogImage` route renderer.
1186
2252
  - [`packages/plugin-schema-jsonld`](https://github.com/heavymoons/ampless/tree/main/packages/plugin-schema-jsonld) — `publicBodyForPost` + `schema` capability; per-post Article JSON-LD. (Phase 4)
1187
2253
 
@@ -1238,7 +2304,7 @@ CLI ships a `plugin <name>` subcommand:
1238
2304
  ```bash
1239
2305
  # Site-local: scaffolds plugins/<name>/index.ts inside the current
1240
2306
  # ampless site. Run from the site repo root.
1241
- npx create-ampless@latest plugin my-thing \
2307
+ npx create-ampless@beta plugin my-thing \
1242
2308
  --trust-level untrusted \
1243
2309
  --capabilities publicHead,adminSettings
1244
2310
 
@@ -1246,7 +2312,7 @@ npx create-ampless@latest plugin my-thing \
1246
2312
  # tsconfig.json, tsup.config.ts, README + .ja, CHANGELOG, .gitignore,
1247
2313
  # and src/index.ts + src/index.test.ts. Run from wherever you want
1248
2314
  # the new package directory to land.
1249
- npx create-ampless@latest plugin @myscope/ampless-plugin-thing \
2315
+ npx create-ampless@beta plugin @myscope/ampless-plugin-thing \
1250
2316
  --standalone \
1251
2317
  --trust-level untrusted \
1252
2318
  --capabilities publicHead,adminSettings \
@@ -1260,7 +2326,7 @@ keyword, and a minimal vitest sample so `pnpm install && pnpm test &&
1260
2326
  pnpm build` runs clean on the freshly generated directory.
1261
2327
 
1262
2328
  Both modes also accept a positional flag-less invocation (`npx
1263
- create-ampless@latest plugin`) that walks you through the same
2329
+ create-ampless@beta plugin`) that walks you through the same
1264
2330
  questions interactively via the @clack prompt UI.
1265
2331
 
1266
2332
  ### Publishing a standalone plugin
@@ -1270,17 +2336,17 @@ cd ampless-plugin-thing
1270
2336
  pnpm install
1271
2337
  pnpm test
1272
2338
  pnpm build
1273
- pnpm publish --access public --tag alpha
2339
+ pnpm publish --access public --tag beta
1274
2340
  ```
1275
2341
 
1276
2342
  `--access public` is mandatory for scoped names (`@scope/...`).
1277
- `--tag alpha` matches the current ampless pre-release cadence — drop
2343
+ `--tag beta` matches the current ampless pre-release cadence — drop
1278
2344
  it once the package reaches a stable major.
1279
2345
 
1280
2346
  There's a publish-to-install lag of "seconds to minutes" before
1281
- `npm install <pkg>@alpha` picks up a fresh publish (CDN + registry
2347
+ `npm install <pkg>@beta` picks up a fresh publish (CDN + registry
1282
2348
  replica propagation). If `npm install` 404s right after `npm publish`
1283
- returns, wait 1-2 minutes and retry — `npm view <pkg>@alpha version`
2349
+ returns, wait 1-2 minutes and retry — `npm view <pkg>@beta version`
1284
2350
  visible in the registry is necessary but sometimes not sufficient.
1285
2351
 
1286
2352
  ### Naming the package
@@ -1331,6 +2397,7 @@ PROTECTED), so the scaffolded code is safe across ampless upgrades.
1331
2397
  - Bugs in the plugin runtime / admin form → same repo, label
1332
2398
  `area:plugins`.
1333
2399
 
1334
- The ampless repo stays private until v1.0 RC. Once it's public, the
1335
- links above resolve to the actual GitHub URLs; today they live in
1336
- the package tarball directly under `node_modules/ampless/docs/`.
2400
+ The GitHub URLs above resolve in the public beta repo. The same docs
2401
+ also live in the package tarball directly under
2402
+ `node_modules/ampless/docs/`, so plugin authors can read them locally
2403
+ without checking out this repo.