@ampless/backend 0.2.0-alpha.2 → 0.2.0-alpha.4

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.
@@ -0,0 +1,17 @@
1
+ import { AppSyncResolverHandler } 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 ListArgs {
10
+ }
11
+ interface SetArgs {
12
+ userId: string;
13
+ role: string;
14
+ }
15
+ declare const handler: AppSyncResolverHandler<ListArgs | SetArgs, AdminUserDto | AdminUserDto[] | null>;
16
+
17
+ 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.info.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
+ };
package/dist/index.d.ts CHANGED
@@ -13,6 +13,7 @@ interface DefineAmplessBackendOpts {
13
13
  processorTrusted: FunctionResource;
14
14
  processorUntrusted: FunctionResource;
15
15
  apiKeyRenewer: FunctionResource;
16
+ userAdmin: FunctionResource;
16
17
  }
17
18
  type AmplessBackend = any;
18
19
  /**
@@ -136,6 +137,17 @@ interface AmplessSchemaModelsOpts {
136
137
  * they must point to actual `.js` files inside the user's project.
137
138
  */
138
139
  resolverPaths?: Partial<AmplessResolverPaths>;
140
+ /**
141
+ * Optional Amplify `defineFunction` ref backing the user-admin
142
+ * AppSync ops (`listAdminUsers` query, `setAdminUserRole` mutation).
143
+ * When supplied, the corresponding `AdminUser` customType + the two
144
+ * ops are added to the schema, both gated to `ampless-admin`.
145
+ *
146
+ * Typed as `unknown` because `defineFunction`'s return type carries
147
+ * internal pnpm paths that don't survive declaration emit — same
148
+ * pattern as `AmplessAuthConfigOpts.postConfirmation`.
149
+ */
150
+ userAdminFunction?: unknown;
139
151
  }
140
152
  /**
141
153
  * The Ampless model + custom query definitions, returned as a plain
@@ -153,6 +165,9 @@ interface AmplessSchemaModelsOpts {
153
165
  * extra models.
154
166
  */
155
167
  declare function amplessSchemaModels(a: any, opts?: AmplessSchemaModelsOpts): {
168
+ AdminUser?: any;
169
+ listAdminUsers?: any;
170
+ setAdminUserRole?: any;
156
171
  Post: any;
157
172
  Page: any;
158
173
  Media: any;
package/dist/index.js CHANGED
@@ -16,7 +16,8 @@ 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
20
21
  });
21
22
  const cfnBucket = backend.storage.resources.cfnResources.cfnBucket;
22
23
  cfnBucket.publicAccessBlockConfiguration = {
@@ -61,6 +62,22 @@ function defineAmplessBackend(opts) {
61
62
  resources: ["arn:aws:cognito-idp:*:*:userpool/*"]
62
63
  })
63
64
  );
65
+ backend.userAdmin.resources.lambda.addToRolePolicy(
66
+ new PolicyStatement({
67
+ effect: Effect.ALLOW,
68
+ actions: [
69
+ "cognito-idp:ListUsers",
70
+ "cognito-idp:AdminListGroupsForUser",
71
+ "cognito-idp:AdminAddUserToGroup",
72
+ "cognito-idp:AdminRemoveUserFromGroup"
73
+ ],
74
+ resources: ["arn:aws:cognito-idp:*:*:userpool/*"]
75
+ })
76
+ );
77
+ backend.userAdmin.resources.lambda.addEnvironment(
78
+ "AMPLESS_USER_POOL_ID",
79
+ backend.auth.resources.userPool.userPoolId
80
+ );
64
81
  const postTable = backend.data.resources.tables["Post"];
65
82
  const cfnPostTable = backend.data.resources.cfnResources.amplifyDynamoDbTables["Post"];
66
83
  cfnPostTable.streamSpecification = { streamViewType: "NEW_AND_OLD_IMAGES" };
@@ -201,11 +218,19 @@ function amplessSchemaModels(a, opts = {}) {
201
218
  slug: a.string().required(),
202
219
  title: a.string().required(),
203
220
  excerpt: a.string(),
204
- format: a.enum(["tiptap", "markdown", "html"]),
221
+ format: a.enum(["tiptap", "markdown", "html", "static"]),
205
222
  body: a.json(),
206
223
  status: a.enum(["draft", "published"]),
207
224
  publishedAt: a.datetime(),
208
225
  tags: a.string().array(),
226
+ // Free-form per-post metadata (JSON). Reserved well-known keys
227
+ // are documented on `PostMetadata` in `ampless/src/types.ts`.
228
+ // Currently:
229
+ // no_layout: boolean — serve the post as bare HTML (no theme
230
+ // chrome). The runtime's post dispatcher redirects to the
231
+ // raw route handler when this is true.
232
+ // Other keys are passed through unchanged for plugin / app use.
233
+ metadata: a.json(),
209
234
  // Denormalized GSI keys — set by every write path (admin client,
210
235
  // MCP tools). Same pattern as siteIdStatus: composing the
211
236
  // partition key as a single string lets each public-read query
@@ -227,7 +252,7 @@ function amplessSchemaModels(a, opts = {}) {
227
252
  pageId: a.id().required(),
228
253
  slug: a.string().required(),
229
254
  title: a.string().required(),
230
- format: a.enum(["tiptap", "markdown", "html"]),
255
+ format: a.enum(["tiptap", "markdown", "html", "static"]),
231
256
  body: a.json(),
232
257
  status: a.enum(["draft", "published"]),
233
258
  publishedAt: a.datetime()
@@ -308,7 +333,8 @@ function amplessSchemaModels(a, opts = {}) {
308
333
  body: a.json(),
309
334
  status: a.string(),
310
335
  publishedAt: a.datetime(),
311
- tags: a.string().array()
336
+ tags: a.string().array(),
337
+ metadata: a.json()
312
338
  }),
313
339
  // Paginated wrapper for list responses.
314
340
  PublicPostConnection: a.customType({
@@ -367,7 +393,27 @@ function amplessSchemaModels(a, opts = {}) {
367
393
  ).authorization((allow) => [
368
394
  allow.publicApiKey(),
369
395
  allow.groups(["ampless-admin", "ampless-editor"])
370
- ])
396
+ ]),
397
+ // User management ops, only wired when the caller supplies a
398
+ // Lambda function ref. Conditionally spread because
399
+ // `a.handler.function(undefined)` is not a valid call — projects
400
+ // that haven't opted into the user-admin Lambda must not see
401
+ // these schema entries.
402
+ ...opts.userAdminFunction ? {
403
+ AdminUser: a.customType({
404
+ userId: a.string().required(),
405
+ email: a.string().required(),
406
+ // 'admin' | 'editor' | 'none' — stored as string because
407
+ // a.enum() in customType doesn't round-trip cleanly across
408
+ // AppSync's typegen + the admin client cast pattern.
409
+ role: a.string().required()
410
+ }),
411
+ listAdminUsers: a.query().returns(a.ref("AdminUser").array()).handler(a.handler.function(opts.userAdminFunction)).authorization((allow) => [allow.groups(["ampless-admin"])]),
412
+ setAdminUserRole: a.mutation().arguments({
413
+ userId: a.string().required(),
414
+ role: a.string().required()
415
+ }).returns(a.ref("AdminUser")).handler(a.handler.function(opts.userAdminFunction)).authorization((allow) => [allow.groups(["ampless-admin"])])
416
+ } : {}
371
417
  };
372
418
  }
373
419
  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.2",
3
+ "version": "0.2.0-alpha.4",
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"
@@ -52,7 +56,7 @@
52
56
  "@aws-sdk/client-sqs": "^3.717.0",
53
57
  "@aws-sdk/lib-dynamodb": "^3.717.0",
54
58
  "@aws-sdk/util-dynamodb": "^3.717.0",
55
- "ampless": "0.2.0-alpha.1"
59
+ "ampless": "0.2.0-alpha.3"
56
60
  },
57
61
  "peerDependencies": {
58
62
  "@aws-amplify/backend": "^1",