@ampless/backend 0.2.0-alpha.1 → 0.2.0-alpha.10

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/README.ja.md ADDED
@@ -0,0 +1,130 @@
1
+ > English: [README.md](./README.md)
2
+ >
3
+
4
+ # @ampless/backend
5
+
6
+ [ampless](https://github.com/heavymoons/ampless) 向け Amplify Gen 2 バックエンドファクトリー。IAM / SQS / DynamoDB ストリームの配線、認証 / データ / ストレージの定義、およびすべてのイベント処理 Lambda を `defineAmplessBackend(...)` ひとつのファクトリーにまとめます。
7
+
8
+ > **プレリリース / アルファ版。** v1.0 まではマイナーバージョンでも破壊的変更が入る可能性があります。
9
+
10
+ テンプレートから切り出すことで、スキャフォールダーを再実行せずに `npm update @ampless/backend` でアップデートできます。バックエンドのバグ修正やインフラ改善はパッケージ経由で届きます。ユーザー側の `amplify/` ツリーは、ファクトリーを組み合わせる 1〜5 行のシェルだけで済みます。
11
+
12
+ ## インストール
13
+
14
+ ```bash
15
+ npm install @ampless/backend@alpha ampless@alpha
16
+ ```
17
+
18
+ ピア依存: `@aws-amplify/backend`(^1)、`aws-cdk-lib`(^2)。CLI スキャフォールダーがテンプレートの `package.json` に互換バージョンをピン留めします。
19
+
20
+ ## 使い方
21
+
22
+ ### `amplify/backend.ts`
23
+
24
+ ```ts
25
+ import { defineAmplessBackend } from '@ampless/backend'
26
+ import { auth } from './auth/resource'
27
+ import { data } from './data/resource'
28
+ import { storage } from './storage/resource'
29
+ import { postConfirmation } from './auth/post-confirmation/resource'
30
+ import { eventDispatcher } from './events/dispatcher/resource'
31
+ import { processorTrusted } from './events/processor-trusted/resource'
32
+ import { processorUntrusted } from './events/processor-untrusted/resource'
33
+ import { apiKeyRenewer } from './functions/api-key-renewer/resource'
34
+
35
+ export default defineAmplessBackend({
36
+ auth, data, storage, postConfirmation,
37
+ eventDispatcher, processorTrusted, processorUntrusted, apiKeyRenewer,
38
+ })
39
+ ```
40
+
41
+ ### `amplify/auth/resource.ts`
42
+
43
+ ```ts
44
+ import { defineAuth } from '@aws-amplify/backend'
45
+ import { amplessAuthConfig } from '@ampless/backend'
46
+ import { postConfirmation } from './post-confirmation/resource'
47
+
48
+ export const auth = defineAuth(amplessAuthConfig({ postConfirmation }))
49
+ ```
50
+
51
+ > `defineAuth` は `amplify/auth/resource.ts` 自体に記述する必要があります。Amplify Gen 2 のインポートパス検証機能が `defineAuth` / `defineData` / `defineStorage` の呼び出し元を検査し、他のファイル(`node_modules/@ampless/backend/...` 配下のラッパーを含む)から呼び出された場合に `Amplify Auth must be defined in amplify/auth/resource.ts` というエラーを投げます。`amplessAuthConfig` は props オブジェクトを返すため、ampless のデフォルト設定を失わずにこのファイルで `defineAuth(...)` を呼び出せます。
52
+
53
+ ### `amplify/data/resource.ts`
54
+
55
+ ```ts
56
+ import { a, defineData, type ClientSchema } from '@aws-amplify/backend'
57
+ import { amplessSchemaModels, defaultAuthorizationModes } from '@ampless/backend'
58
+
59
+ const schema = a.schema({
60
+ ...amplessSchemaModels(a),
61
+ // カスタムモデルをここに追加 — 組み込みモデルと同居します:
62
+ // MyCustomModel: a.model({ ... }).authorization((allow) => [...]),
63
+ })
64
+
65
+ export type Schema = ClientSchema<typeof schema>
66
+ export const data = defineData({ schema, authorizationModes: defaultAuthorizationModes })
67
+ ```
68
+
69
+ 3 つの AppSync JS リゾルバーファイル(`list-published-posts.js`、`get-published-post.js`、`list-posts-by-tag.js`)はテンプレート内に残ります — AppSync がリゾルバーの `entry` パスを CDK synth 時に `defineData` を呼び出すファイルからの相対パスで解決するため、pnpm でシンボリックリンクされた `node_modules` パスは解決を通過できません。`amplify/data/` 以外の場所に移動する場合は、`amplessSchemaModels(a, { resolverPaths })` で新しいパスを渡してください。
70
+
71
+ ### `amplify/storage/resource.ts`
72
+
73
+ ```ts
74
+ import { defineStorage } from '@aws-amplify/backend'
75
+ import { amplessStorageConfig } from '@ampless/backend'
76
+ export const storage = defineStorage(amplessStorageConfig())
77
+ ```
78
+
79
+ > 認証と同じインポートパス制約があります — `defineStorage` はこのファイルから直接呼び出す必要があります。`amplessStorageConfig` は props オブジェクトを返します。
80
+
81
+ ### Lambda 薄型シェル
82
+
83
+ `amplify/auth/`、`amplify/events/`、`amplify/functions/` 内の各ハンドラーファイルは 1〜3 行の re-export になります。Amplify の esbuild がこのパッケージへのインポートを追跡し、実際のハンドラーコードを Lambda アーティファクトにバンドルします。
84
+
85
+ ```ts
86
+ // amplify/auth/post-confirmation/handler.ts
87
+ export { handler } from '@ampless/backend/auth/post-confirmation'
88
+
89
+ // amplify/events/dispatcher/handler.ts
90
+ export { handler } from '@ampless/backend/events/dispatcher'
91
+
92
+ // amplify/events/processor-trusted/handler.ts
93
+ import config from '../../../cms.config'
94
+ import { createProcessorTrustedHandler } from '@ampless/backend/events/processor-trusted'
95
+ export const handler = createProcessorTrustedHandler({
96
+ plugins: config.plugins,
97
+ site: config.site,
98
+ })
99
+
100
+ // amplify/events/processor-untrusted/handler.ts
101
+ import config from '../../../cms.config'
102
+ import { createProcessorUntrustedHandler } from '@ampless/backend/events/processor-untrusted'
103
+ export const handler = createProcessorUntrustedHandler({
104
+ plugins: config.plugins,
105
+ site: config.site,
106
+ })
107
+
108
+ // amplify/functions/api-key-renewer/handler.ts
109
+ export { handler } from '@ampless/backend/functions/api-key-renewer'
110
+ ```
111
+
112
+ ## サブパス
113
+
114
+ - `@ampless/backend` — `defineAmplessBackend`、`amplessAuthConfig`、`amplessStorageConfig`、`amplessSchemaModels`、`extendAmplessSchema`、`defaultAuthorizationModes`
115
+ - `@ampless/backend/auth/post-confirmation` — Lambda ハンドラー
116
+ - `@ampless/backend/events/dispatcher` — Lambda ハンドラー
117
+ - `@ampless/backend/events/processor-trusted` — `createProcessorTrustedHandler({ plugins, site })`
118
+ - `@ampless/backend/events/processor-untrusted` — `createProcessorUntrustedHandler({ plugins, site })`
119
+ - `@ampless/backend/functions/api-key-renewer` — Lambda ハンドラー
120
+
121
+ ## テンプレートに残るもの
122
+
123
+ - `amplify/data/*.js` — AppSync JS リゾルバー(ファイルパス制約のため)。
124
+ - すべての `resource.ts` と `handler.ts` — 薄型シェルですが、Amplify の CDK synth がユーザー側で解決する `entry: './handler.ts'` パスを保持しています。
125
+ - `cms.config.ts` と `themes-registry.ts` — ユーザーが所有するカスタマイズサーフェス。
126
+ - `templates/<theme>/` 以下のテーマコンポーネント — ユーザーが所有。
127
+
128
+ ## ライセンス
129
+
130
+ MIT
package/README.md CHANGED
@@ -1,3 +1,6 @@
1
+ > 日本語版: [README.ja.md](./README.ja.md)
2
+ >
3
+
1
4
  # @ampless/backend
2
5
 
3
6
  Amplify Gen 2 backend factories for [ampless](https://github.com/heavymoons/ampless). Bundles the IAM / SQS / DynamoDB-stream wiring, the auth / data / storage definitions, and every event-processing Lambda behind one `defineAmplessBackend(...)` factory.
@@ -0,0 +1,49 @@
1
+ import { Handler } from 'aws-lambda';
2
+
3
+ type AdminRole = 'admin' | 'editor' | 'none';
4
+ interface AdminUserDto {
5
+ userId: string;
6
+ email: string;
7
+ role: AdminRole;
8
+ }
9
+ interface SetArgs {
10
+ userId: string;
11
+ role: string;
12
+ }
13
+ /**
14
+ * Event shape that Amplify Gen 2 actually delivers to a Lambda data
15
+ * source attached via `a.handler.function()`. The CDK generated
16
+ * AppSync resource is a PIPELINE resolver whose Lambda-invocation
17
+ * function uses a VTL request mapping template that emits a flat
18
+ * payload:
19
+ *
20
+ * {
21
+ * "operation": "Invoke",
22
+ * "payload": {
23
+ * "typeName": "Query",
24
+ * "fieldName": "listAdminUsers",
25
+ * "arguments": { ... },
26
+ * "identity": { ... },
27
+ * "source": ..., "request": ..., "prev": ...
28
+ * }
29
+ * }
30
+ *
31
+ * Notably this is NOT the canonical `AppSyncResolverEvent` shape
32
+ * (which has `event.info.fieldName`). The `aws-lambda` package's
33
+ * `AppSyncResolverHandler` type is misleading here — typing the
34
+ * handler with it sends `event.info.fieldName` to `undefined` and
35
+ * blows up at runtime with
36
+ * `Cannot read properties of undefined (reading 'fieldName')`.
37
+ */
38
+ interface UserAdminEvent {
39
+ typeName: string;
40
+ fieldName: string;
41
+ arguments: Partial<SetArgs>;
42
+ identity?: unknown;
43
+ source?: unknown;
44
+ request?: unknown;
45
+ prev?: unknown;
46
+ }
47
+ declare const handler: Handler<UserAdminEvent, AdminUserDto | AdminUserDto[] | null>;
48
+
49
+ export { handler };
@@ -0,0 +1,105 @@
1
+ // src/auth/user-admin.ts
2
+ import {
3
+ CognitoIdentityProviderClient,
4
+ ListUsersCommand,
5
+ AdminListGroupsForUserCommand,
6
+ AdminAddUserToGroupCommand,
7
+ AdminRemoveUserFromGroupCommand
8
+ } from "@aws-sdk/client-cognito-identity-provider";
9
+ var cognito = new CognitoIdentityProviderClient({});
10
+ var ADMIN_GROUP = "ampless-admin";
11
+ var EDITOR_GROUP = "ampless-editor";
12
+ function requireUserPoolId() {
13
+ const v = process.env.AMPLESS_USER_POOL_ID;
14
+ if (!v) {
15
+ console.error("[user-admin] missing required env var AMPLESS_USER_POOL_ID");
16
+ throw new Error("user-admin: missing required env var AMPLESS_USER_POOL_ID");
17
+ }
18
+ return v;
19
+ }
20
+ function emailOf(user) {
21
+ const attr = user.Attributes?.find((a) => a.Name === "email");
22
+ return attr?.Value ?? "";
23
+ }
24
+ async function roleOf(userPoolId, username) {
25
+ const { Groups } = await cognito.send(
26
+ new AdminListGroupsForUserCommand({ UserPoolId: userPoolId, Username: username })
27
+ );
28
+ const names = (Groups ?? []).map((g) => g.GroupName);
29
+ if (names.includes(ADMIN_GROUP)) return "admin";
30
+ if (names.includes(EDITOR_GROUP)) return "editor";
31
+ return "none";
32
+ }
33
+ async function listAdminUsers(userPoolId) {
34
+ const { Users } = await cognito.send(
35
+ new ListUsersCommand({ UserPoolId: userPoolId, Limit: 60 })
36
+ );
37
+ const users = Users ?? [];
38
+ const out = [];
39
+ for (const u of users) {
40
+ if (!u.Username) continue;
41
+ const role = await roleOf(userPoolId, u.Username);
42
+ out.push({ userId: u.Username, email: emailOf(u), role });
43
+ }
44
+ return out;
45
+ }
46
+ async function setAdminUserRole(userPoolId, userId, role) {
47
+ for (const group of [ADMIN_GROUP, EDITOR_GROUP]) {
48
+ await cognito.send(
49
+ new AdminRemoveUserFromGroupCommand({
50
+ UserPoolId: userPoolId,
51
+ Username: userId,
52
+ GroupName: group
53
+ })
54
+ );
55
+ }
56
+ if (role !== "none") {
57
+ await cognito.send(
58
+ new AdminAddUserToGroupCommand({
59
+ UserPoolId: userPoolId,
60
+ Username: userId,
61
+ GroupName: role === "admin" ? ADMIN_GROUP : EDITOR_GROUP
62
+ })
63
+ );
64
+ }
65
+ const { Users } = await cognito.send(
66
+ new ListUsersCommand({
67
+ UserPoolId: userPoolId,
68
+ Filter: `sub = "${userId}"`,
69
+ Limit: 1
70
+ })
71
+ );
72
+ const user = Users?.[0];
73
+ const email = user ? emailOf(user) : "";
74
+ const finalRole = await roleOf(userPoolId, userId);
75
+ return { userId, email, role: finalRole };
76
+ }
77
+ var handler = async (event) => {
78
+ const userPoolId = requireUserPoolId();
79
+ const field = event.fieldName;
80
+ try {
81
+ if (field === "listAdminUsers") {
82
+ return await listAdminUsers(userPoolId);
83
+ }
84
+ if (field === "setAdminUserRole") {
85
+ const { userId, role } = event.arguments;
86
+ if (role !== "admin" && role !== "editor" && role !== "none") {
87
+ console.error(`[user-admin] invalid role: ${role}`);
88
+ throw new Error(`Invalid role: ${role}`);
89
+ }
90
+ if (!userId) {
91
+ console.error("[user-admin] missing userId argument");
92
+ throw new Error("Missing userId argument");
93
+ }
94
+ return await setAdminUserRole(userPoolId, userId, role);
95
+ }
96
+ console.error(`[user-admin] unsupported fieldName: ${field}`);
97
+ throw new Error(`Unsupported fieldName: ${field}`);
98
+ } catch (err) {
99
+ console.error(`[user-admin] ${field} failed:`, err);
100
+ throw err;
101
+ }
102
+ };
103
+ export {
104
+ handler
105
+ };
@@ -1,5 +1,5 @@
1
1
  import { SQSHandler } from 'aws-lambda';
2
- import { AmplessPlugin, Config } from 'ampless';
2
+ import { Config } from 'ampless';
3
3
 
4
4
  interface CreateProcessorTrustedHandlerOpts {
5
5
  /**
@@ -7,8 +7,12 @@ interface CreateProcessorTrustedHandlerOpts {
7
7
  * trusted plugins itself so callers don't need to remember the
8
8
  * filter, and so adding `privileged` later only touches the handler
9
9
  * code in this package.
10
+ *
11
+ * Accepts the raw `Config['plugins']` shape (which permits string
12
+ * entries for future dynamic loading) — the runtime filter discards
13
+ * anything that isn't a plugin object.
10
14
  */
11
- plugins?: AmplessPlugin[];
15
+ plugins?: Config['plugins'];
12
16
  /**
13
17
  * The `cms.config.site` block, surfaced to plugin hooks via
14
18
  * `ctx.site`. Pass through from the thin shell — handlers must
@@ -1,12 +1,16 @@
1
1
  import { SQSHandler } from 'aws-lambda';
2
- import { AmplessPlugin, Config } from 'ampless';
2
+ import { Config } from 'ampless';
3
3
 
4
4
  interface CreateProcessorUntrustedHandlerOpts {
5
5
  /**
6
6
  * The full `cms.config.plugins` array. The handler filters down to
7
7
  * untrusted plugins itself.
8
+ *
9
+ * Accepts the raw `Config['plugins']` shape (which permits string
10
+ * entries for future dynamic loading) — the runtime filter discards
11
+ * anything that isn't a plugin object.
8
12
  */
9
- plugins?: AmplessPlugin[];
13
+ plugins?: Config['plugins'];
10
14
  /**
11
15
  * The `cms.config.site` block, surfaced to plugin hooks via
12
16
  * `ctx.site` (read-only — untrusted plugins have no AWS-touching
@@ -0,0 +1,33 @@
1
+ /**
2
+ * MCP HTTP endpoint Lambda. Phase 3: Bearer token validation only.
3
+ *
4
+ * Reads the KvStore table directly (PK = 'mcp-tokens', SK = SHA-256
5
+ * hash of the plaintext token) instead of going through AppSync — the
6
+ * Lambda IAM role only needs `dynamodb:GetItem` on that one table,
7
+ * which is much narrower than `appsync:GraphQL` and avoids the
8
+ * IAM-auth-mode complexity of letting Lambdas call AppSync as
9
+ * privileged identity. Phase 4 will add AppSync IAM auth when tool
10
+ * dispatch actually needs schema-aware access.
11
+ *
12
+ * Function URL event format: Lambda Function URLs emit API Gateway
13
+ * HTTP v2 events (https://docs.aws.amazon.com/lambda/latest/dg/urls-invocation.html#urls-payloads).
14
+ * Headers arrive lowercased.
15
+ */
16
+ interface FunctionUrlEvent {
17
+ headers?: Record<string, string | undefined>;
18
+ body?: string;
19
+ isBase64Encoded?: boolean;
20
+ requestContext?: {
21
+ http?: {
22
+ method?: string;
23
+ };
24
+ };
25
+ }
26
+ interface FunctionUrlResult {
27
+ statusCode: number;
28
+ headers?: Record<string, string>;
29
+ body: string;
30
+ }
31
+ declare const handler: (event: FunctionUrlEvent) => Promise<FunctionUrlResult>;
32
+
33
+ export { handler };
@@ -0,0 +1,70 @@
1
+ // src/functions/mcp-handler.ts
2
+ import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
3
+ import { DynamoDBDocumentClient, GetCommand } from "@aws-sdk/lib-dynamodb";
4
+ import { createHash } from "crypto";
5
+ var TOKENS_PK = "mcp-tokens";
6
+ function requireEnv(name) {
7
+ const v = process.env[name];
8
+ if (!v) throw new Error(`[mcp-handler] missing required env var ${name}`);
9
+ return v;
10
+ }
11
+ var KV_TABLE = requireEnv("AMPLESS_KV_TABLE");
12
+ var ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
13
+ function hashToken(plain) {
14
+ return createHash("sha256").update(plain).digest("hex");
15
+ }
16
+ function jsonResponse(statusCode, body) {
17
+ return {
18
+ statusCode,
19
+ headers: { "content-type": "application/json" },
20
+ body: JSON.stringify(body)
21
+ };
22
+ }
23
+ async function validateBearer(plaintext) {
24
+ const hash = hashToken(plaintext);
25
+ const res = await ddb.send(
26
+ new GetCommand({
27
+ TableName: KV_TABLE,
28
+ Key: { pk: TOKENS_PK, sk: hash }
29
+ })
30
+ );
31
+ const row = res.Item;
32
+ if (!row?.value) return null;
33
+ let meta;
34
+ try {
35
+ meta = JSON.parse(row.value);
36
+ } catch {
37
+ console.error("[mcp-handler] could not parse token row", { hash });
38
+ return null;
39
+ }
40
+ if (meta.revokedAt) return null;
41
+ if (meta.expiresAt && new Date(meta.expiresAt).getTime() <= Date.now()) return null;
42
+ return meta;
43
+ }
44
+ var handler = async (event) => {
45
+ if (event.requestContext?.http?.method === "OPTIONS") {
46
+ return { statusCode: 204, body: "" };
47
+ }
48
+ const authHeader = event.headers?.authorization ?? event.headers?.Authorization;
49
+ if (!authHeader) {
50
+ return jsonResponse(401, { error: "missing_authorization" });
51
+ }
52
+ const match = /^Bearer\s+(amk_[A-Za-z0-9_-]+)$/.exec(authHeader);
53
+ if (!match) {
54
+ return jsonResponse(401, { error: "invalid_authorization" });
55
+ }
56
+ const plaintext = match[1];
57
+ const meta = await validateBearer(plaintext);
58
+ if (!meta) {
59
+ return jsonResponse(401, { error: "invalid_token" });
60
+ }
61
+ return jsonResponse(200, {
62
+ ok: true,
63
+ tokenPrefix: meta.prefix,
64
+ scope: meta.scope,
65
+ note: "Phase 3 stub. JSON-RPC tool dispatch lands in Phase 4."
66
+ });
67
+ };
68
+ export {
69
+ handler
70
+ };
package/dist/index.d.ts CHANGED
@@ -13,6 +13,8 @@ interface DefineAmplessBackendOpts {
13
13
  processorTrusted: FunctionResource;
14
14
  processorUntrusted: FunctionResource;
15
15
  apiKeyRenewer: FunctionResource;
16
+ userAdmin: FunctionResource;
17
+ mcpHandler: FunctionResource;
16
18
  }
17
19
  type AmplessBackend = any;
18
20
  /**
@@ -136,6 +138,17 @@ interface AmplessSchemaModelsOpts {
136
138
  * they must point to actual `.js` files inside the user's project.
137
139
  */
138
140
  resolverPaths?: Partial<AmplessResolverPaths>;
141
+ /**
142
+ * Optional Amplify `defineFunction` ref backing the user-admin
143
+ * AppSync ops (`listAdminUsers` query, `setAdminUserRole` mutation).
144
+ * When supplied, the corresponding `AdminUser` customType + the two
145
+ * ops are added to the schema, both gated to `ampless-admin`.
146
+ *
147
+ * Typed as `unknown` because `defineFunction`'s return type carries
148
+ * internal pnpm paths that don't survive declaration emit — same
149
+ * pattern as `AmplessAuthConfigOpts.postConfirmation`.
150
+ */
151
+ userAdminFunction?: unknown;
139
152
  }
140
153
  /**
141
154
  * The Ampless model + custom query definitions, returned as a plain
@@ -153,6 +166,9 @@ interface AmplessSchemaModelsOpts {
153
166
  * extra models.
154
167
  */
155
168
  declare function amplessSchemaModels(a: any, opts?: AmplessSchemaModelsOpts): {
169
+ AdminUser?: any;
170
+ listAdminUsers?: any;
171
+ setAdminUserRole?: any;
156
172
  Post: any;
157
173
  Page: any;
158
174
  Media: any;
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { defineBackend } from "@aws-amplify/backend";
3
3
  import { Effect, PolicyStatement, AnyPrincipal } from "aws-cdk-lib/aws-iam";
4
4
  import { Duration, Stack } from "aws-cdk-lib";
5
5
  import { Queue } from "aws-cdk-lib/aws-sqs";
6
- import { StartingPosition } from "aws-cdk-lib/aws-lambda";
6
+ import { FunctionUrlAuthType, HttpMethod, StartingPosition } from "aws-cdk-lib/aws-lambda";
7
7
  import { DynamoEventSource, SqsEventSource } from "aws-cdk-lib/aws-lambda-event-sources";
8
8
  import { Rule, Schedule } from "aws-cdk-lib/aws-events";
9
9
  import { LambdaFunction } from "aws-cdk-lib/aws-events-targets";
@@ -16,7 +16,9 @@ function defineAmplessBackend(opts) {
16
16
  eventDispatcher: opts.eventDispatcher,
17
17
  processorTrusted: opts.processorTrusted,
18
18
  processorUntrusted: opts.processorUntrusted,
19
- apiKeyRenewer: opts.apiKeyRenewer
19
+ apiKeyRenewer: opts.apiKeyRenewer,
20
+ userAdmin: opts.userAdmin,
21
+ mcpHandler: opts.mcpHandler
20
22
  });
21
23
  const cfnBucket = backend.storage.resources.cfnResources.cfnBucket;
22
24
  cfnBucket.publicAccessBlockConfiguration = {
@@ -61,6 +63,22 @@ function defineAmplessBackend(opts) {
61
63
  resources: ["arn:aws:cognito-idp:*:*:userpool/*"]
62
64
  })
63
65
  );
66
+ backend.userAdmin.resources.lambda.addToRolePolicy(
67
+ new PolicyStatement({
68
+ effect: Effect.ALLOW,
69
+ actions: [
70
+ "cognito-idp:ListUsers",
71
+ "cognito-idp:AdminListGroupsForUser",
72
+ "cognito-idp:AdminAddUserToGroup",
73
+ "cognito-idp:AdminRemoveUserFromGroup"
74
+ ],
75
+ resources: ["arn:aws:cognito-idp:*:*:userpool/*"]
76
+ })
77
+ );
78
+ backend.userAdmin.resources.lambda.addEnvironment(
79
+ "AMPLESS_USER_POOL_ID",
80
+ backend.auth.resources.userPool.userPoolId
81
+ );
64
82
  const postTable = backend.data.resources.tables["Post"];
65
83
  const cfnPostTable = backend.data.resources.cfnResources.amplifyDynamoDbTables["Post"];
66
84
  cfnPostTable.streamSpecification = { streamViewType: "NEW_AND_OLD_IMAGES" };
@@ -149,6 +167,31 @@ function defineAmplessBackend(opts) {
149
167
  schedule: Schedule.cron({ minute: "0", hour: "3", day: "1", month: "*", year: "*" }),
150
168
  targets: [new LambdaFunction(apiKeyRenewerFn)]
151
169
  });
170
+ const mcpHandlerFn = backend.mcpHandler.resources.lambda;
171
+ mcpHandlerFn.addToRolePolicy(
172
+ new PolicyStatement({
173
+ effect: Effect.ALLOW,
174
+ actions: ["dynamodb:GetItem"],
175
+ resources: [kvTable.tableArn]
176
+ })
177
+ );
178
+ mcpHandlerFn.addEnvironment("AMPLESS_KV_TABLE", kvTable.tableName);
179
+ const mcpFunctionUrl = mcpHandlerFn.addFunctionUrl({
180
+ authType: FunctionUrlAuthType.NONE,
181
+ cors: {
182
+ allowedOrigins: ["*"],
183
+ allowedMethods: [HttpMethod.POST],
184
+ allowedHeaders: ["*"],
185
+ maxAge: Duration.hours(1)
186
+ }
187
+ });
188
+ backend.addOutput({
189
+ custom: {
190
+ mcp: {
191
+ endpoint: mcpFunctionUrl.url
192
+ }
193
+ }
194
+ });
152
195
  return backend;
153
196
  }
154
197
 
@@ -201,11 +244,19 @@ function amplessSchemaModels(a, opts = {}) {
201
244
  slug: a.string().required(),
202
245
  title: a.string().required(),
203
246
  excerpt: a.string(),
204
- format: a.enum(["tiptap", "markdown", "html"]),
247
+ format: a.enum(["tiptap", "markdown", "html", "static"]),
205
248
  body: a.json(),
206
249
  status: a.enum(["draft", "published"]),
207
250
  publishedAt: a.datetime(),
208
251
  tags: a.string().array(),
252
+ // Free-form per-post metadata (JSON). Reserved well-known keys
253
+ // are documented on `PostMetadata` in `ampless/src/types.ts`.
254
+ // Currently:
255
+ // no_layout: boolean — serve the post as bare HTML (no theme
256
+ // chrome). The runtime's post dispatcher redirects to the
257
+ // raw route handler when this is true.
258
+ // Other keys are passed through unchanged for plugin / app use.
259
+ metadata: a.json(),
209
260
  // Denormalized GSI keys — set by every write path (admin client,
210
261
  // MCP tools). Same pattern as siteIdStatus: composing the
211
262
  // partition key as a single string lets each public-read query
@@ -227,7 +278,7 @@ function amplessSchemaModels(a, opts = {}) {
227
278
  pageId: a.id().required(),
228
279
  slug: a.string().required(),
229
280
  title: a.string().required(),
230
- format: a.enum(["tiptap", "markdown", "html"]),
281
+ format: a.enum(["tiptap", "markdown", "html", "static"]),
231
282
  body: a.json(),
232
283
  status: a.enum(["draft", "published"]),
233
284
  publishedAt: a.datetime()
@@ -308,7 +359,8 @@ function amplessSchemaModels(a, opts = {}) {
308
359
  body: a.json(),
309
360
  status: a.string(),
310
361
  publishedAt: a.datetime(),
311
- tags: a.string().array()
362
+ tags: a.string().array(),
363
+ metadata: a.json()
312
364
  }),
313
365
  // Paginated wrapper for list responses.
314
366
  PublicPostConnection: a.customType({
@@ -367,7 +419,27 @@ function amplessSchemaModels(a, opts = {}) {
367
419
  ).authorization((allow) => [
368
420
  allow.publicApiKey(),
369
421
  allow.groups(["ampless-admin", "ampless-editor"])
370
- ])
422
+ ]),
423
+ // User management ops, only wired when the caller supplies a
424
+ // Lambda function ref. Conditionally spread because
425
+ // `a.handler.function(undefined)` is not a valid call — projects
426
+ // that haven't opted into the user-admin Lambda must not see
427
+ // these schema entries.
428
+ ...opts.userAdminFunction ? {
429
+ AdminUser: a.customType({
430
+ userId: a.string().required(),
431
+ email: a.string().required(),
432
+ // 'admin' | 'editor' | 'none' — stored as string because
433
+ // a.enum() in customType doesn't round-trip cleanly across
434
+ // AppSync's typegen + the admin client cast pattern.
435
+ role: a.string().required()
436
+ }),
437
+ listAdminUsers: a.query().returns(a.ref("AdminUser").array()).handler(a.handler.function(opts.userAdminFunction)).authorization((allow) => [allow.groups(["ampless-admin"])]),
438
+ setAdminUserRole: a.mutation().arguments({
439
+ userId: a.string().required(),
440
+ role: a.string().required()
441
+ }).returns(a.ref("AdminUser")).handler(a.handler.function(opts.userAdminFunction)).authorization((allow) => [allow.groups(["ampless-admin"])])
442
+ } : {}
371
443
  };
372
444
  }
373
445
  function extendAmplessSchema(a, custom, opts) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ampless/backend",
3
- "version": "0.2.0-alpha.1",
3
+ "version": "0.2.0-alpha.10",
4
4
  "description": "Amplify Gen 2 backend factories for ampless: auth, data, storage, event processors, API key renewer",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -13,6 +13,10 @@
13
13
  "import": "./dist/auth/post-confirmation.js",
14
14
  "types": "./dist/auth/post-confirmation.d.ts"
15
15
  },
16
+ "./auth/user-admin": {
17
+ "import": "./dist/auth/user-admin.js",
18
+ "types": "./dist/auth/user-admin.d.ts"
19
+ },
16
20
  "./events/dispatcher": {
17
21
  "import": "./dist/events/dispatcher.js",
18
22
  "types": "./dist/events/dispatcher.d.ts"
@@ -28,6 +32,10 @@
28
32
  "./functions/api-key-renewer": {
29
33
  "import": "./dist/functions/api-key-renewer.js",
30
34
  "types": "./dist/functions/api-key-renewer.d.ts"
35
+ },
36
+ "./functions/mcp-handler": {
37
+ "import": "./dist/functions/mcp-handler.js",
38
+ "types": "./dist/functions/mcp-handler.d.ts"
31
39
  }
32
40
  },
33
41
  "files": [
@@ -52,7 +60,7 @@
52
60
  "@aws-sdk/client-sqs": "^3.717.0",
53
61
  "@aws-sdk/lib-dynamodb": "^3.717.0",
54
62
  "@aws-sdk/util-dynamodb": "^3.717.0",
55
- "ampless": "0.2.0-alpha.1"
63
+ "ampless": "0.2.0-alpha.6"
56
64
  },
57
65
  "peerDependencies": {
58
66
  "@aws-amplify/backend": "^1",