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