create-ampless 0.2.0-alpha.0

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 (135) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +38 -0
  3. package/dist/index.d.ts +2 -0
  4. package/dist/index.js +229 -0
  5. package/dist/templates/_shared/RUNBOOK.md +178 -0
  6. package/dist/templates/_shared/amplify/auth/post-confirmation/handler.ts +4 -0
  7. package/dist/templates/_shared/amplify/auth/post-confirmation/resource.ts +6 -0
  8. package/dist/templates/_shared/amplify/auth/resource.ts +8 -0
  9. package/dist/templates/_shared/amplify/backend.ts +29 -0
  10. package/dist/templates/_shared/amplify/data/get-published-post.js +33 -0
  11. package/dist/templates/_shared/amplify/data/list-posts-by-tag.js +52 -0
  12. package/dist/templates/_shared/amplify/data/list-published-posts.js +57 -0
  13. package/dist/templates/_shared/amplify/data/resource.ts +30 -0
  14. package/dist/templates/_shared/amplify/events/dispatcher/handler.ts +4 -0
  15. package/dist/templates/_shared/amplify/events/dispatcher/resource.ts +12 -0
  16. package/dist/templates/_shared/amplify/events/processor-trusted/handler.ts +12 -0
  17. package/dist/templates/_shared/amplify/events/processor-trusted/resource.ts +14 -0
  18. package/dist/templates/_shared/amplify/events/processor-untrusted/handler.ts +10 -0
  19. package/dist/templates/_shared/amplify/events/processor-untrusted/resource.ts +9 -0
  20. package/dist/templates/_shared/amplify/functions/api-key-renewer/handler.ts +4 -0
  21. package/dist/templates/_shared/amplify/functions/api-key-renewer/resource.ts +12 -0
  22. package/dist/templates/_shared/amplify/storage/resource.ts +7 -0
  23. package/dist/templates/_shared/app/(admin)/admin/layout.tsx +4 -0
  24. package/dist/templates/_shared/app/(admin)/admin/media/page.tsx +4 -0
  25. package/dist/templates/_shared/app/(admin)/admin/page.tsx +4 -0
  26. package/dist/templates/_shared/app/(admin)/admin/posts/[postId]/page.tsx +4 -0
  27. package/dist/templates/_shared/app/(admin)/admin/posts/new/page.tsx +4 -0
  28. package/dist/templates/_shared/app/(admin)/admin/posts/page.tsx +4 -0
  29. package/dist/templates/_shared/app/(admin)/admin/sites/[siteId]/page.tsx +5 -0
  30. package/dist/templates/_shared/app/(admin)/admin/sites/[siteId]/theme/page.tsx +6 -0
  31. package/dist/templates/_shared/app/(admin)/admin/sites/page.tsx +5 -0
  32. package/dist/templates/_shared/app/api/media/[...path]/route.ts +5 -0
  33. package/dist/templates/_shared/app/globals.css +114 -0
  34. package/dist/templates/_shared/app/layout.tsx +48 -0
  35. package/dist/templates/_shared/app/login/page.tsx +4 -0
  36. package/dist/templates/_shared/app/providers.tsx +13 -0
  37. package/dist/templates/_shared/app/site/[siteId]/[slug]/page.tsx +10 -0
  38. package/dist/templates/_shared/app/site/[siteId]/feed.xml/route.ts +5 -0
  39. package/dist/templates/_shared/app/site/[siteId]/og/[slug]/route.ts +6 -0
  40. package/dist/templates/_shared/app/site/[siteId]/page.tsx +10 -0
  41. package/dist/templates/_shared/app/site/[siteId]/raw/[slug]/route.ts +5 -0
  42. package/dist/templates/_shared/app/site/[siteId]/sitemap.xml/route.ts +5 -0
  43. package/dist/templates/_shared/app/site/[siteId]/tag/[tag]/page.tsx +10 -0
  44. package/dist/templates/_shared/cms.config.ts +110 -0
  45. package/dist/templates/_shared/components/i18n-provider.tsx +7 -0
  46. package/dist/templates/_shared/components/lightbox-content.tsx +69 -0
  47. package/dist/templates/_shared/components/site-chrome/collapsible-sidebar.tsx +54 -0
  48. package/dist/templates/_shared/components/site-chrome/mobile-menu.tsx +68 -0
  49. package/dist/templates/_shared/components/site-chrome/site-footer.tsx +43 -0
  50. package/dist/templates/_shared/components/site-chrome/site-header.tsx +94 -0
  51. package/dist/templates/_shared/components/site-chrome/site-sidebar.tsx +81 -0
  52. package/dist/templates/_shared/components/tag-list.tsx +25 -0
  53. package/dist/templates/_shared/components.json +21 -0
  54. package/dist/templates/_shared/lib/admin-site-client.ts +10 -0
  55. package/dist/templates/_shared/lib/admin-site.ts +8 -0
  56. package/dist/templates/_shared/lib/admin.ts +24 -0
  57. package/dist/templates/_shared/lib/ampless.ts +23 -0
  58. package/dist/templates/_shared/lib/amplify-server.ts +7 -0
  59. package/dist/templates/_shared/lib/amplify.ts +9 -0
  60. package/dist/templates/_shared/lib/auth-server.ts +11 -0
  61. package/dist/templates/_shared/lib/cn.ts +5 -0
  62. package/dist/templates/_shared/lib/i18n.ts +31 -0
  63. package/dist/templates/_shared/lib/kv-provider.ts +7 -0
  64. package/dist/templates/_shared/lib/media.ts +6 -0
  65. package/dist/templates/_shared/lib/posts-provider.ts +7 -0
  66. package/dist/templates/_shared/lib/posts-public.ts +19 -0
  67. package/dist/templates/_shared/lib/posts.ts +12 -0
  68. package/dist/templates/_shared/lib/seo.ts +8 -0
  69. package/dist/templates/_shared/lib/site-settings.ts +8 -0
  70. package/dist/templates/_shared/lib/storage.ts +7 -0
  71. package/dist/templates/_shared/lib/theme-actions.ts +5 -0
  72. package/dist/templates/_shared/lib/theme-active.ts +8 -0
  73. package/dist/templates/_shared/lib/theme-config.ts +10 -0
  74. package/dist/templates/_shared/lib/upload.ts +6 -0
  75. package/dist/templates/_shared/middleware.ts +13 -0
  76. package/dist/templates/_shared/next.config.mjs +11 -0
  77. package/dist/templates/_shared/package.json +63 -0
  78. package/dist/templates/_shared/postcss.config.mjs +5 -0
  79. package/dist/templates/_shared/themes-registry.ts +38 -0
  80. package/dist/templates/_shared/tsconfig.json +23 -0
  81. package/dist/templates/blog/README.md +52 -0
  82. package/dist/templates/blog/index.ts +29 -0
  83. package/dist/templates/blog/manifest.ts +144 -0
  84. package/dist/templates/blog/pages/feed.ts +31 -0
  85. package/dist/templates/blog/pages/home.tsx +108 -0
  86. package/dist/templates/blog/pages/post.tsx +94 -0
  87. package/dist/templates/blog/pages/sitemap.ts +30 -0
  88. package/dist/templates/blog/pages/tag.tsx +76 -0
  89. package/dist/templates/blog/tokens.css +54 -0
  90. package/dist/templates/corporate/README.md +20 -0
  91. package/dist/templates/corporate/index.ts +25 -0
  92. package/dist/templates/corporate/manifest.ts +94 -0
  93. package/dist/templates/corporate/pages/feed.ts +29 -0
  94. package/dist/templates/corporate/pages/home.tsx +130 -0
  95. package/dist/templates/corporate/pages/post.tsx +96 -0
  96. package/dist/templates/corporate/pages/sitemap.ts +28 -0
  97. package/dist/templates/corporate/pages/tag.tsx +81 -0
  98. package/dist/templates/corporate/tokens.css +47 -0
  99. package/dist/templates/dads/README.md +35 -0
  100. package/dist/templates/dads/index.ts +25 -0
  101. package/dist/templates/dads/manifest.ts +84 -0
  102. package/dist/templates/dads/pages/feed.ts +29 -0
  103. package/dist/templates/dads/pages/home.tsx +126 -0
  104. package/dist/templates/dads/pages/post.tsx +102 -0
  105. package/dist/templates/dads/pages/sitemap.ts +28 -0
  106. package/dist/templates/dads/pages/tag.tsx +86 -0
  107. package/dist/templates/dads/tokens.css +67 -0
  108. package/dist/templates/docs/README.md +27 -0
  109. package/dist/templates/docs/index.ts +25 -0
  110. package/dist/templates/docs/manifest.ts +89 -0
  111. package/dist/templates/docs/pages/feed.ts +29 -0
  112. package/dist/templates/docs/pages/home.tsx +88 -0
  113. package/dist/templates/docs/pages/post.tsx +96 -0
  114. package/dist/templates/docs/pages/sitemap.ts +28 -0
  115. package/dist/templates/docs/pages/tag.tsx +79 -0
  116. package/dist/templates/docs/tokens.css +55 -0
  117. package/dist/templates/landing/README.md +25 -0
  118. package/dist/templates/landing/index.ts +25 -0
  119. package/dist/templates/landing/manifest.ts +118 -0
  120. package/dist/templates/landing/pages/feed.ts +31 -0
  121. package/dist/templates/landing/pages/home.tsx +123 -0
  122. package/dist/templates/landing/pages/post.tsx +95 -0
  123. package/dist/templates/landing/pages/sitemap.ts +28 -0
  124. package/dist/templates/landing/pages/tag.tsx +85 -0
  125. package/dist/templates/landing/tokens.css +47 -0
  126. package/dist/templates/minimal/README.md +52 -0
  127. package/dist/templates/minimal/index.ts +25 -0
  128. package/dist/templates/minimal/manifest.ts +35 -0
  129. package/dist/templates/minimal/pages/feed.ts +31 -0
  130. package/dist/templates/minimal/pages/home.tsx +44 -0
  131. package/dist/templates/minimal/pages/post.tsx +65 -0
  132. package/dist/templates/minimal/pages/sitemap.ts +30 -0
  133. package/dist/templates/minimal/pages/tag.tsx +46 -0
  134. package/dist/templates/minimal/tokens.css +46 -0
  135. package/package.json +41 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ampless contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # create-ampless
2
+
3
+ CLI scaffolding tool for [ampless](https://github.com/heavymoons/ampless) projects.
4
+
5
+ > **Pre-release / alpha.** Breaking changes possible in any minor version until v1.0. Use the `@alpha` tag (the `@latest` tag won't exist until v1.0).
6
+
7
+ ```bash
8
+ npx create-ampless@alpha
9
+ ```
10
+
11
+ The wizard walks you through:
12
+
13
+ 1. Project name
14
+ 2. Site name (used as the default `<title>` and OGP `siteName`)
15
+ 3. Theme — `blog` for v0.1
16
+ 4. Plugins — `seo`, `rss`, `webhook`
17
+
18
+ Output is a Next.js 15 (App Router) project with the AWS Amplify Gen 2 backend definitions, an admin panel at `/admin`, public blog at `/`, the chosen plugins pre-wired in `cms.config.ts`, and a `RUNBOOK.md` for operations notes.
19
+
20
+ ## Next steps inside the generated project
21
+
22
+ ```bash
23
+ cd my-project
24
+ npm install
25
+ npx ampx sandbox # provision AWS dev resources, generates amplify_outputs.json
26
+ npm run dev # http://localhost:3000
27
+ ```
28
+
29
+ Sign up at `/login` — the first registered user is automatically promoted to the `ampless-admin` Cognito group.
30
+
31
+ ## Requirements
32
+
33
+ - Node.js >= 20
34
+ - AWS account + `aws configure` already set up (sandbox / pipeline-deploy talk to AWS directly)
35
+
36
+ ## License
37
+
38
+ [MIT](../../LICENSE)
@@ -0,0 +1,2 @@
1
+
2
+ export { }
package/dist/index.js ADDED
@@ -0,0 +1,229 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { spinner, outro, log } from "@clack/prompts";
5
+ import { existsSync as existsSync2 } from "fs";
6
+ import { resolve as resolve3 } from "path";
7
+
8
+ // src/prompts.ts
9
+ import * as p from "@clack/prompts";
10
+ async function runPrompts(argProjectName) {
11
+ p.intro("create-ampless");
12
+ const result = await p.group(
13
+ {
14
+ projectName: () => p.text({
15
+ message: "Project name",
16
+ placeholder: "my-ampless-site",
17
+ defaultValue: argProjectName ?? "my-ampless-site",
18
+ validate: (v) => {
19
+ if (!v.trim()) return "Project name is required";
20
+ if (!/^[a-z0-9-_]+$/.test(v)) return "Use lowercase letters, numbers, hyphens, underscores";
21
+ }
22
+ }),
23
+ siteName: () => p.text({
24
+ message: "Site display name",
25
+ placeholder: "My Blog",
26
+ defaultValue: "My Blog"
27
+ }),
28
+ // Multiple themes can ship side-by-side. The first selected is the
29
+ // default active theme; admins can switch per-site at runtime. Add
30
+ // / remove themes later by editing themes-registry.ts and
31
+ // themes/<name>/.
32
+ themes: () => p.multiselect({
33
+ message: "Themes to install (space to toggle)",
34
+ options: [
35
+ { value: "blog", label: "Blog \u2014 neutral monochrome (shadcn default)" },
36
+ { value: "minimal", label: "Minimal \u2014 soft blue accent on warm neutral" },
37
+ { value: "landing", label: "Landing \u2014 hero-led marketing page" },
38
+ { value: "corporate", label: "Corporate \u2014 slate / navy company site" },
39
+ { value: "docs", label: "Docs \u2014 sidebar-led docs (tag-driven sections)" },
40
+ { value: "dads", label: "DADS \u2014 Digital Agency Design System (Japanese government style)" }
41
+ ],
42
+ initialValues: ["blog"],
43
+ required: true
44
+ }),
45
+ plugins: () => p.multiselect({
46
+ message: "Plugins (space to toggle)",
47
+ options: [
48
+ { value: "seo", label: "SEO \u2014 meta tags, OGP, sitemap", hint: "recommended" },
49
+ { value: "rss", label: "RSS \u2014 /feed.xml" },
50
+ { value: "webhook", label: "Webhook \u2014 POST events to external URLs" }
51
+ ],
52
+ initialValues: ["seo"],
53
+ required: false
54
+ })
55
+ },
56
+ {
57
+ onCancel: () => {
58
+ p.cancel("Cancelled.");
59
+ process.exit(0);
60
+ }
61
+ }
62
+ );
63
+ const themes = result.themes;
64
+ if (themes.length === 0) {
65
+ p.cancel("At least one theme must be selected.");
66
+ return null;
67
+ }
68
+ const confirmed = await p.confirm({
69
+ message: `Create project "${result.projectName}"?`,
70
+ initialValue: true
71
+ });
72
+ if (p.isCancel(confirmed) || !confirmed) {
73
+ p.cancel("Cancelled.");
74
+ return null;
75
+ }
76
+ return {
77
+ projectName: result.projectName,
78
+ siteName: result.siteName,
79
+ themes,
80
+ defaultTheme: themes[0],
81
+ plugins: result.plugins
82
+ };
83
+ }
84
+
85
+ // src/scaffold.ts
86
+ import { cp, readFile, writeFile, readdir, mkdir, rm } from "fs/promises";
87
+ import { join, extname, resolve as resolve2 } from "path";
88
+
89
+ // src/templates.ts
90
+ import { existsSync } from "fs";
91
+ import { fileURLToPath } from "url";
92
+ import { resolve } from "path";
93
+ function resolveTemplatesDir() {
94
+ const distDir = resolve(fileURLToPath(import.meta.url), "..");
95
+ const bundled = resolve(distDir, "templates");
96
+ if (existsSync(bundled)) return bundled;
97
+ return resolve(distDir, "..", "..", "..", "templates");
98
+ }
99
+ var templatesDir = resolveTemplatesDir();
100
+ function sharedTemplateDir() {
101
+ return resolve(templatesDir, "_shared");
102
+ }
103
+ function templatePath(theme) {
104
+ return resolve(templatesDir, theme);
105
+ }
106
+
107
+ // src/scaffold.ts
108
+ var TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
109
+ ".json",
110
+ ".md",
111
+ ".ts",
112
+ ".tsx",
113
+ ".js",
114
+ ".jsx",
115
+ ".mjs",
116
+ ".cjs",
117
+ ".html",
118
+ ".css",
119
+ ".env",
120
+ ".txt",
121
+ ".yaml",
122
+ ".yml",
123
+ ".toml",
124
+ ".gitignore"
125
+ ]);
126
+ async function substituteFile(filePath, vars) {
127
+ const ext = extname(filePath) || filePath.endsWith(".gitignore") ? ".gitignore" : "";
128
+ if (!TEXT_EXTENSIONS.has(ext)) return;
129
+ const content = await readFile(filePath, "utf-8");
130
+ const replaced = content.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? `{{${key}}}`);
131
+ if (replaced !== content) await writeFile(filePath, replaced, "utf-8");
132
+ }
133
+ async function substituteDir(dirPath, vars) {
134
+ const entries = await readdir(dirPath, { withFileTypes: true });
135
+ await Promise.all(
136
+ entries.map(async (entry) => {
137
+ const fullPath = join(dirPath, entry.name);
138
+ if (entry.isDirectory()) {
139
+ await substituteDir(fullPath, vars);
140
+ } else {
141
+ await substituteFile(fullPath, vars);
142
+ }
143
+ })
144
+ );
145
+ }
146
+ function buildRegistry(themes) {
147
+ const imports = themes.map((t) => `import ${t} from '@/themes/${t}'`).join("\n");
148
+ const map = themes.map((t) => ` ${t},`).join("\n");
149
+ return `// Generated by create-ampless. Lists every theme installed under
150
+ // \`themes/<name>/\`. Adding a theme = drop a directory under themes/,
151
+ // add an import + map entry below, and redeploy.
152
+ //
153
+ // \`theme.active\` (per-site, in KvStore) picks which theme renders for
154
+ // each request; everything listed here gets bundled so switching is
155
+ // instant.
156
+
157
+ ${imports}
158
+
159
+ export const themes = {
160
+ ${map}
161
+ } as const
162
+
163
+ export type ThemeName = keyof typeof themes
164
+
165
+ export const themeList = Object.values(themes)
166
+
167
+ export const DEFAULT_THEME: ThemeName = (themeList[0]?.name as ThemeName) ?? '${themes[0]}'
168
+ `;
169
+ }
170
+ async function scaffold(sharedDir, themesRoot, destDir, opts) {
171
+ await cp(sharedDir, destDir, { recursive: true });
172
+ const destThemesDir = resolve2(destDir, "themes");
173
+ await rm(destThemesDir, { recursive: true, force: true });
174
+ await mkdir(destThemesDir, { recursive: true });
175
+ for (const theme of opts.themes) {
176
+ const src = templatePath(theme);
177
+ const dst = resolve2(destThemesDir, theme);
178
+ await cp(src, dst, { recursive: true });
179
+ }
180
+ void themesRoot;
181
+ const registryPath = resolve2(destDir, "themes-registry.ts");
182
+ await writeFile(registryPath, buildRegistry(opts.themes), "utf-8");
183
+ const vars = {
184
+ projectName: opts.projectName,
185
+ siteName: opts.siteName,
186
+ year: String((/* @__PURE__ */ new Date()).getFullYear()),
187
+ plugins: JSON.stringify(opts.plugins),
188
+ themes: JSON.stringify(opts.themes),
189
+ defaultTheme: opts.defaultTheme
190
+ };
191
+ await substituteDir(destDir, vars);
192
+ }
193
+
194
+ // src/index.ts
195
+ import pc from "picocolors";
196
+ async function main() {
197
+ const argProjectName = process.argv[2];
198
+ const opts = await runPrompts(argProjectName);
199
+ if (!opts) return;
200
+ const destDir = resolve3(process.cwd(), opts.projectName);
201
+ if (existsSync2(destDir)) {
202
+ log.error(`Directory already exists: ${destDir}`);
203
+ process.exit(1);
204
+ }
205
+ const sharedDir = sharedTemplateDir();
206
+ const s = spinner();
207
+ s.start("Scaffolding project...");
208
+ try {
209
+ await scaffold(sharedDir, templatesDir, destDir, opts);
210
+ s.stop("Done!");
211
+ } catch (err) {
212
+ s.stop("Failed.");
213
+ log.error(String(err));
214
+ process.exit(1);
215
+ }
216
+ outro(
217
+ `${pc.green("\u2714")} Project created at ${pc.bold(opts.projectName)}
218
+
219
+ Next steps:
220
+ ${pc.cyan("cd")} ${opts.projectName}
221
+ ${pc.cyan("npm install")}
222
+ ${pc.cyan("npx ampx sandbox")} ${pc.dim("# start Amplify backend")}
223
+ ${pc.cyan("npm run dev")} ${pc.dim("# start Next.js")}`
224
+ );
225
+ }
226
+ main().catch((err) => {
227
+ console.error(err);
228
+ process.exit(1);
229
+ });
@@ -0,0 +1,178 @@
1
+ # Runbook
2
+
3
+ Operational tasks for an ampless-powered site.
4
+
5
+ ## AppSync API key (auto-renewed)
6
+
7
+ Public blog reads (`listPublishedPosts`, `getPublishedPost`,
8
+ `listPostsByTag`) are gated by an AppSync API key. The key lives in
9
+ `amplify_outputs.json` and is therefore **visible to anyone visiting
10
+ the public site** — treat it as a low-trust credential. Its only
11
+ privilege is calling the three custom queries, which themselves only
12
+ return rows where `status === 'published'`.
13
+
14
+ ### Why an API key (and not the Identity Pool guest role)?
15
+
16
+ Amplify Gen 2 `a.handler.custom` resolvers don't support `allow.guest()`
17
+ or `allow.authenticated('identityPool')` — only apiKey / userPool /
18
+ lambda / group / owner. v0.1 chose API key for simplicity; switching
19
+ the public reads to a Lambda function data source (`a.handler.function`)
20
+ is a v0.2 candidate.
21
+
22
+ ### Auto-renewal — no rotation runbook required
23
+
24
+ The `api-key-renewer` Lambda (see `amplify/functions/api-key-renewer/`)
25
+ is invoked by an EventBridge schedule on the 1st of every month at
26
+ 03:00 UTC. It calls `AppSync.UpdateApiKey` to push `expires` to
27
+ "now + 364 days" on the existing key, so:
28
+
29
+ - the key id never changes,
30
+ - `amplify_outputs.json` stays valid,
31
+ - the Next.js app does not need to be rebuilt,
32
+ - at any moment, the key has at least ~334 days of remaining validity.
33
+
34
+ If you want to inspect or trigger it manually:
35
+
36
+ ```bash
37
+ # verify current expiry
38
+ aws appsync list-api-keys \
39
+ --region <region-from-amplify_outputs.json:data.aws_region> \
40
+ --api-id <api-id-derived-from-amplify_outputs.json:data.url>
41
+
42
+ # manual run (e.g. after a long sandbox pause)
43
+ aws lambda invoke \
44
+ --function-name $(aws lambda list-functions \
45
+ --query "Functions[?contains(FunctionName,'api-key-renewer')].FunctionName | [0]" \
46
+ --output text) \
47
+ /tmp/out.json && cat /tmp/out.json
48
+ ```
49
+
50
+ ### If a key is suspected leaked
51
+
52
+ Immediate response is to rotate the key value (not just push expiry):
53
+
54
+ 1. In `amplify/data/resource.ts`, edit a comment to force a CFN update
55
+ 2. Run `npx ampx sandbox` (sandbox) or `npx ampx pipeline-deploy ...`
56
+ (production) — Amplify regenerates the key value
57
+ 3. Re-deploy the Next.js app so SSR picks up the new `data.api_key`
58
+
59
+ ## Common operations
60
+
61
+ ### Promote / demote a user
62
+
63
+ Use the AWS Cognito console:
64
+
65
+ 1. User Pool → Users → pick the user
66
+ 2. Group memberships → Add to / remove from group
67
+ 3. Have the user sign out and back in for the new claims to apply
68
+
69
+ Groups: `ampless-admin` (full CRUD + ops), `ampless-editor` (content
70
+ CRUD), `ampless-reader` (reserved for future REST/MCP API consumers).
71
+
72
+ ### Reset a user's password (admin override)
73
+
74
+ If someone is locked out and email-based recovery isn't an option:
75
+
76
+ ```bash
77
+ aws cognito-idp admin-set-user-password \
78
+ --user-pool-id <pool-id-from-amplify_outputs.json:auth.user_pool_id> \
79
+ --region <region> \
80
+ --username <email> \
81
+ --password '<new-password>' --permanent
82
+ ```
83
+
84
+ The `/login` page also has a self-service "Forgot password?" flow.
85
+
86
+ ### Restore from a Post-table backup
87
+
88
+ DynamoDB Point-in-Time Recovery is **not** enabled by `defineData` in
89
+ v0.1; turn it on manually via AWS Console → DynamoDB → Tables →
90
+ `<your post table>` → Backups → Edit PITR. Once enabled, restoration
91
+ takes the form `aws dynamodb restore-table-to-point-in-time` to a new
92
+ table; you'll need to migrate items back to the live table afterwards.
93
+
94
+ ### Inspect failed plugin events
95
+
96
+ Failed processor invocations land in the shared events DLQ created in
97
+ `amplify/backend.ts` (`EventsDlq`). View messages via the SQS console
98
+ or `aws sqs receive-message --queue-url <dlq-url> --max-number-of-messages 10`.
99
+ There's no automated alarm in v0.1 — periodic manual checks recommended,
100
+ or wire up a CloudWatch alarm on `ApproximateNumberOfMessagesVisible`.
101
+
102
+ ## Multi-site / custom domains
103
+
104
+ ampless can serve multiple sites from one Amplify Hosting deployment.
105
+ Each site is identified by a `siteId` and bound to one or more
106
+ hostnames via `cms.config.ts`:
107
+
108
+ ```ts
109
+ sites: {
110
+ blog: {
111
+ domains: ['blog.example.com', 'www.example.com'],
112
+ name: 'My Blog',
113
+ url: 'https://blog.example.com',
114
+ },
115
+ docs: {
116
+ domains: ['docs.example.com'],
117
+ name: 'Docs',
118
+ url: 'https://docs.example.com',
119
+ },
120
+ },
121
+ ```
122
+
123
+ The middleware (`middleware.ts`) maps incoming `Host` to a `siteId` and
124
+ internally rewrites the path to `/_sites/{siteId}/...`. Subdomains and
125
+ fully separate domains are equivalent at the application layer — only
126
+ the AWS-side wiring differs.
127
+
128
+ ### Single domain operation
129
+
130
+ If `sites` is undefined or has only one entry, ampless runs in
131
+ single-site mode (`siteId='default'`). SSR responses follow each page's
132
+ own caching directives (so you can opt into CloudFront caching with
133
+ `Cache-Control: public, s-maxage=...` per route).
134
+
135
+ ### Multi-site mode caveat: SSR caching is force-disabled
136
+
137
+ When two or more sites are declared, the middleware adds
138
+ `Cache-Control: private, no-store` to every public response. This is
139
+ because Amplify Hosting's CloudFront does not include `Host` in its
140
+ cache key — leaving caching on would let `https://site1/foo` and
141
+ `https://site2/foo` cross-contaminate at the edge. The trade-off is
142
+ that every public read hits Lambda. Lifting it requires moving off
143
+ Amplify Hosting onto a self-managed CloudFront + Open Next stack
144
+ (roadmap: post-v1.0).
145
+
146
+ ### Adding a custom domain to Amplify Hosting
147
+
148
+ For each domain you want to bind:
149
+
150
+ 1. **Amplify Hosting console** → your app → **Domain management** →
151
+ **Add domain**.
152
+ 2. Enter the apex domain (`example.com`) and the subdomains you want
153
+ to attach. Amplify provisions an ACM certificate and a CloudFront
154
+ SAN entry automatically.
155
+ 3. Update DNS:
156
+ - **Same DNS provider as Route 53 / Amplify managed**: Amplify
157
+ creates the CNAMEs for you, just confirm.
158
+ - **External DNS** (Cloudflare, Squarespace, etc.): Amplify shows
159
+ CNAME / DNS verification records to copy. ACM email-validation
160
+ also works as a fallback.
161
+ 4. Wait for the **Domain activation** to finish (typically 15–60
162
+ minutes; certificate validation is the slow step).
163
+ 5. Add the new domain to the matching `sites.{id}.domains[]` in
164
+ `cms.config.ts` and redeploy:
165
+ ```bash
166
+ git add cms.config.ts && git commit -m "feat: add docs.example.com"
167
+ git push # Amplify Hosting picks it up
168
+ ```
169
+
170
+ Verify end-to-end:
171
+
172
+ ```bash
173
+ curl -I https://docs.example.com/ # 200 with the docs site's HTML
174
+ curl -sI https://docs.example.com/ | grep -i cache # Cache-Control: private, no-store
175
+ ```
176
+
177
+ If the request returns `404 Site not found` instead, the host is not
178
+ listed in any `sites.*.domains[]` — fix the config and redeploy.
@@ -0,0 +1,4 @@
1
+ // Re-exported from @ampless/backend so the package can ship Lambda
2
+ // handler updates via `npm update`. Amplify's esbuild follows this
3
+ // import and bundles the real handler into the Lambda artifact.
4
+ export { handler } from '@ampless/backend/auth/post-confirmation'
@@ -0,0 +1,6 @@
1
+ import { defineFunction } from '@aws-amplify/backend'
2
+
3
+ export const postConfirmation = defineFunction({
4
+ name: 'post-confirmation',
5
+ entry: './handler.ts',
6
+ })
@@ -0,0 +1,8 @@
1
+ import { defineAmplessAuth } from '@ampless/backend'
2
+ import { postConfirmation } from './post-confirmation/resource.js'
3
+
4
+ // Provisions a Cognito User Pool + Identity Pool with the three role
5
+ // groups (ampless-admin, ampless-editor, ampless-reader) and wires in
6
+ // the post-confirmation Lambda that promotes the first confirmed user
7
+ // to ampless-admin.
8
+ export const auth = defineAmplessAuth({ postConfirmation })
@@ -0,0 +1,29 @@
1
+ import { defineAmplessBackend } from '@ampless/backend'
2
+
3
+ import { auth } from './auth/resource.js'
4
+ import { data } from './data/resource.js'
5
+ import { storage } from './storage/resource.js'
6
+ import { postConfirmation } from './auth/post-confirmation/resource.js'
7
+ import { eventDispatcher } from './events/dispatcher/resource.js'
8
+ import { processorTrusted } from './events/processor-trusted/resource.js'
9
+ import { processorUntrusted } from './events/processor-untrusted/resource.js'
10
+ import { apiKeyRenewer } from './functions/api-key-renewer/resource.js'
11
+
12
+ // `defineAmplessBackend` provisions auth, data, storage, the event
13
+ // system (DynamoDB Streams → SQS-trusted / SQS-untrusted → trust_level
14
+ // Lambdas), the AppSync API key renewer, and every IAM / CORS /
15
+ // password policy override. Add custom CDK constructs / IAM policies
16
+ // below by mutating the returned `backend` — `defineAmplessBackend`
17
+ // returns the same object Amplify Gen 2's `defineBackend` does.
18
+ const backend = defineAmplessBackend({
19
+ auth,
20
+ data,
21
+ storage,
22
+ postConfirmation,
23
+ eventDispatcher,
24
+ processorTrusted,
25
+ processorUntrusted,
26
+ apiKeyRenewer,
27
+ })
28
+
29
+ export default backend
@@ -0,0 +1,33 @@
1
+ import { util } from '@aws-appsync/utils'
2
+
3
+ // AppSync JS resolver: returns a single published post by slug.
4
+ //
5
+ // Reads the `bySiteIdSlug` GSI: PK = `${siteId}#${slug}`. A given
6
+ // (site, slug) tuple identifies at most one row, so this is an O(1)
7
+ // PK Query — no scan, no filter, no per-partition limit issues.
8
+ //
9
+ // Drafts are dropped in the response handler. The admin form
10
+ // enforces a unique slug per site at the application layer, but if
11
+ // somehow draft + published share a slug we prefer the published row.
12
+ export function request(ctx) {
13
+ const siteId = ctx.args.siteId ?? 'default'
14
+ const slug = ctx.args.slug
15
+ const partition = `${siteId}#${slug}`
16
+ return {
17
+ operation: 'Query',
18
+ index: 'bySiteIdSlug',
19
+ query: {
20
+ expression: '#siteIdSlug = :siteIdSlug',
21
+ expressionNames: { '#siteIdSlug': 'siteIdSlug' },
22
+ expressionValues: util.dynamodb.toMapValues({ ':siteIdSlug': partition }),
23
+ },
24
+ limit: 5,
25
+ }
26
+ }
27
+
28
+ export function response(ctx) {
29
+ if (ctx.error) util.error(ctx.error.message, ctx.error.type)
30
+ const items = ctx.result.items ?? []
31
+ const published = items.find((i) => i.status === 'published')
32
+ return published ?? null
33
+ }
@@ -0,0 +1,52 @@
1
+ import { util } from '@aws-appsync/utils'
2
+
3
+ // AppSync JS resolver: list published posts for a given tag, newest first.
4
+ // Reads the denormalized PostTag table where:
5
+ // PK = `${siteId}#${tag}`
6
+ // SK = `${publishedAt}#${postId}` (so descending SK = newest first)
7
+ //
8
+ // Authorization is enforced by AppSync; the resolver itself only encodes
9
+ // the tag/site partition condition. Drafts never appear here because the
10
+ // admin client only writes PostTag rows for posts whose status is
11
+ // 'published'.
12
+ export function request(ctx) {
13
+ const { siteId = 'default', tag, limit, nextToken } = ctx.args
14
+ return {
15
+ operation: 'Query',
16
+ query: {
17
+ expression: '#siteIdTag = :siteIdTag',
18
+ expressionNames: { '#siteIdTag': 'siteIdTag' },
19
+ expressionValues: util.dynamodb.toMapValues({
20
+ ':siteIdTag': `${siteId}#${tag}`,
21
+ }),
22
+ },
23
+ scanIndexForward: false, // newest first (SK descends)
24
+ limit: limit ?? 20,
25
+ nextToken: nextToken ?? undefined,
26
+ }
27
+ }
28
+
29
+ export function response(ctx) {
30
+ if (ctx.error) util.error(ctx.error.message, ctx.error.type)
31
+
32
+ // PostTag rows are summary records (no `body`). Map them to the same
33
+ // PublicPost shape `listPublishedPosts` returns; the detail view should
34
+ // call `getPublishedPost(slug)` for the full body.
35
+ const items = (ctx.result.items ?? []).map((row) => ({
36
+ siteId: row.siteId,
37
+ postId: row.postId,
38
+ slug: row.slug,
39
+ title: row.title,
40
+ excerpt: row.excerpt ?? null,
41
+ format: 'markdown',
42
+ body: null,
43
+ status: 'published',
44
+ publishedAt: row.publishedAt,
45
+ tags: row.tags ?? [],
46
+ }))
47
+
48
+ return {
49
+ items,
50
+ nextToken: ctx.result.nextToken ?? null,
51
+ }
52
+ }
@@ -0,0 +1,57 @@
1
+ import { util } from '@aws-appsync/utils'
2
+
3
+ // AppSync JS resolver: list a site's published posts, newest first.
4
+ //
5
+ // Reads the `bySiteIdStatus` GSI:
6
+ // PK = `${siteId}#${status}` (denormalized field set by writers)
7
+ // SK = publishedAt
8
+ // so a single Query reads only one site's published partition. Drafts
9
+ // never appear because the PK condition pins status='published'.
10
+ //
11
+ // Date-range filtering (`from`, `to`) is pushed into the SK condition,
12
+ // so DynamoDB only reads the matching range. `nextToken` paginates
13
+ // without re-issuing a fresh query.
14
+ export function request(ctx) {
15
+ const { siteId = 'default', from, to, limit, nextToken } = ctx.args
16
+
17
+ const partition = `${siteId}#published`
18
+ let keyExpression = '#siteIdStatus = :siteIdStatus'
19
+ const expressionNames = { '#siteIdStatus': 'siteIdStatus' }
20
+ const expressionValueMap = { ':siteIdStatus': partition }
21
+
22
+ if (from && to) {
23
+ keyExpression += ' AND #publishedAt BETWEEN :from AND :to'
24
+ expressionNames['#publishedAt'] = 'publishedAt'
25
+ expressionValueMap[':from'] = from
26
+ expressionValueMap[':to'] = to
27
+ } else if (from) {
28
+ keyExpression += ' AND #publishedAt >= :from'
29
+ expressionNames['#publishedAt'] = 'publishedAt'
30
+ expressionValueMap[':from'] = from
31
+ } else if (to) {
32
+ keyExpression += ' AND #publishedAt <= :to'
33
+ expressionNames['#publishedAt'] = 'publishedAt'
34
+ expressionValueMap[':to'] = to
35
+ }
36
+
37
+ return {
38
+ operation: 'Query',
39
+ index: 'bySiteIdStatus',
40
+ query: {
41
+ expression: keyExpression,
42
+ expressionNames,
43
+ expressionValues: util.dynamodb.toMapValues(expressionValueMap),
44
+ },
45
+ scanIndexForward: false, // newest first
46
+ limit: limit ?? 20,
47
+ nextToken: nextToken ?? undefined,
48
+ }
49
+ }
50
+
51
+ export function response(ctx) {
52
+ if (ctx.error) util.error(ctx.error.message, ctx.error.type)
53
+ return {
54
+ items: ctx.result.items ?? [],
55
+ nextToken: ctx.result.nextToken ?? null,
56
+ }
57
+ }