puls-dev 0.2.0 → 0.2.2

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 (64) hide show
  1. package/README.md +1 -1
  2. package/dist/core/config.d.ts +5 -0
  3. package/dist/providers/aws/api.d.ts +4 -0
  4. package/dist/providers/aws/api.js +4 -0
  5. package/dist/providers/aws/cloudwatch.d.ts +44 -0
  6. package/dist/providers/aws/cloudwatch.js +205 -0
  7. package/dist/providers/aws/cloudwatch.test.d.ts +1 -0
  8. package/dist/providers/aws/cloudwatch.test.js +224 -0
  9. package/dist/providers/aws/fargate.d.ts +2 -0
  10. package/dist/providers/aws/fargate.js +6 -0
  11. package/dist/providers/aws/iam.d.ts +52 -0
  12. package/dist/providers/aws/iam.js +307 -0
  13. package/dist/providers/aws/iam.test.d.ts +1 -0
  14. package/dist/providers/aws/iam.test.js +367 -0
  15. package/dist/providers/aws/index.d.ts +7 -0
  16. package/dist/providers/aws/index.js +7 -0
  17. package/dist/providers/aws/lambda.d.ts +3 -1
  18. package/dist/providers/aws/lambda.js +11 -2
  19. package/dist/providers/aws/rds.d.ts +1 -0
  20. package/dist/providers/aws/rds.js +4 -1
  21. package/dist/providers/aws/sns.d.ts +22 -0
  22. package/dist/providers/aws/sns.js +146 -0
  23. package/dist/providers/aws/sns.test.d.ts +1 -0
  24. package/dist/providers/aws/sns.test.js +162 -0
  25. package/dist/providers/firebase/appcheck.d.ts +15 -0
  26. package/dist/providers/firebase/appcheck.js +109 -0
  27. package/dist/providers/firebase/appcheck.test.d.ts +1 -0
  28. package/dist/providers/firebase/appcheck.test.js +141 -0
  29. package/dist/providers/firebase/index.d.ts +2 -0
  30. package/dist/providers/firebase/index.js +2 -0
  31. package/dist/providers/gcp/api.d.ts +10 -0
  32. package/dist/providers/gcp/api.js +111 -0
  33. package/dist/providers/gcp/clouddns.d.ts +37 -0
  34. package/dist/providers/gcp/clouddns.js +284 -0
  35. package/dist/providers/gcp/clouddns.test.d.ts +1 -0
  36. package/dist/providers/gcp/clouddns.test.js +259 -0
  37. package/dist/providers/gcp/cloudrun.d.ts +31 -0
  38. package/dist/providers/gcp/cloudrun.js +240 -0
  39. package/dist/providers/gcp/cloudrun.test.d.ts +1 -0
  40. package/dist/providers/gcp/cloudrun.test.js +281 -0
  41. package/dist/providers/gcp/cloudsql.d.ts +37 -0
  42. package/dist/providers/gcp/cloudsql.js +262 -0
  43. package/dist/providers/gcp/cloudsql.test.d.ts +1 -0
  44. package/dist/providers/gcp/cloudsql.test.js +295 -0
  45. package/dist/providers/gcp/iam.d.ts +38 -0
  46. package/dist/providers/gcp/iam.js +309 -0
  47. package/dist/providers/gcp/iam.test.d.ts +1 -0
  48. package/dist/providers/gcp/iam.test.js +305 -0
  49. package/dist/providers/gcp/index.d.ts +19 -0
  50. package/dist/providers/gcp/index.js +19 -0
  51. package/dist/providers/gcp/pubsub.d.ts +31 -0
  52. package/dist/providers/gcp/pubsub.js +227 -0
  53. package/dist/providers/gcp/pubsub.test.d.ts +1 -0
  54. package/dist/providers/gcp/pubsub.test.js +244 -0
  55. package/dist/providers/gcp/secrets.d.ts +21 -0
  56. package/dist/providers/gcp/secrets.js +187 -0
  57. package/dist/providers/gcp/secrets.test.d.ts +1 -0
  58. package/dist/providers/gcp/secrets.test.js +264 -0
  59. package/dist/providers/proxmox/vm.d.ts +2 -0
  60. package/dist/providers/proxmox/vm.js +35 -3
  61. package/dist/providers/proxmox/vm.test.d.ts +1 -0
  62. package/dist/providers/proxmox/vm.test.js +155 -0
  63. package/dist/types/aws.d.ts +11 -0
  64. package/package.json +32 -2
@@ -7,6 +7,9 @@ import { FargateBuilder } from "./fargate.js";
7
7
  import { RDSBuilder } from "./rds.js";
8
8
  import { SQSBuilder } from "./sqs.js";
9
9
  import { SecretsBuilder } from "./secrets.js";
10
+ import { IAMRoleBuilder, IAMPolicyBuilder } from "./iam.js";
11
+ import { SNSTopicBuilder } from "./sns.js";
12
+ import { CloudWatchAlarmBuilder } from "./cloudwatch.js";
10
13
  export declare const AWS: {
11
14
  init: (opts: {
12
15
  region: string;
@@ -20,5 +23,9 @@ export declare const AWS: {
20
23
  RDS: (name: string) => RDSBuilder;
21
24
  SQS: (name: string) => SQSBuilder;
22
25
  Secret: (secretId: string) => SecretsBuilder;
26
+ IAMRole: (name: string) => IAMRoleBuilder;
27
+ IAMPolicy: (name: string) => IAMPolicyBuilder;
28
+ SNS: (name: string) => SNSTopicBuilder;
29
+ Alarm: (name: string) => CloudWatchAlarmBuilder;
23
30
  };
24
31
  export * from "../../types/aws.js";
@@ -8,6 +8,9 @@ import { FargateBuilder } from "./fargate.js";
8
8
  import { RDSBuilder } from "./rds.js";
9
9
  import { SQSBuilder } from "./sqs.js";
10
10
  import { SecretsBuilder } from "./secrets.js";
11
+ import { IAMRoleBuilder, IAMPolicyBuilder } from "./iam.js";
12
+ import { SNSTopicBuilder } from "./sns.js";
13
+ import { CloudWatchAlarmBuilder } from "./cloudwatch.js";
11
14
  export const AWS = {
12
15
  init: (opts) => {
13
16
  Config.set({
@@ -26,5 +29,9 @@ export const AWS = {
26
29
  RDS: (name) => new RDSBuilder(name),
27
30
  SQS: (name) => new SQSBuilder(name),
28
31
  Secret: (secretId) => new SecretsBuilder(secretId),
32
+ IAMRole: (name) => new IAMRoleBuilder(name),
33
+ IAMPolicy: (name) => new IAMPolicyBuilder(name),
34
+ SNS: (name) => new SNSTopicBuilder(name),
35
+ Alarm: (name) => new CloudWatchAlarmBuilder(name),
29
36
  };
30
37
  export * from "../../types/aws.js";
@@ -1,5 +1,6 @@
1
1
  import { BaseBuilder } from "../../core/resource.js";
2
2
  import { SecretsBuilder } from "./secrets.js";
3
+ import { IAMRoleBuilder } from "./iam.js";
3
4
  export declare class LambdaBuilder extends BaseBuilder {
4
5
  private _runtime;
5
6
  private _handler;
@@ -8,6 +9,7 @@ export declare class LambdaBuilder extends BaseBuilder {
8
9
  private _codePath?;
9
10
  private _env;
10
11
  private _roleArn?;
12
+ private _roleBuilder?;
11
13
  resolvedArn: string | null;
12
14
  constructor(name: string);
13
15
  private discoverFunction;
@@ -16,7 +18,7 @@ export declare class LambdaBuilder extends BaseBuilder {
16
18
  handler(h: string): this;
17
19
  memory(mb: number): this;
18
20
  timeout(seconds: number): this;
19
- role(arn: string): this;
21
+ role(arnOrBuilder: string | IAMRoleBuilder): this;
20
22
  env(vars: Record<string, string | SecretsBuilder>): this;
21
23
  private ensureRole;
22
24
  private buildZip;
@@ -25,6 +25,7 @@ export class LambdaBuilder extends BaseBuilder {
25
25
  _codePath;
26
26
  _env = {};
27
27
  _roleArn;
28
+ _roleBuilder;
28
29
  resolvedArn = null;
29
30
  constructor(name) {
30
31
  super(name);
@@ -64,8 +65,13 @@ export class LambdaBuilder extends BaseBuilder {
64
65
  this._timeout = seconds;
65
66
  return this;
66
67
  }
67
- role(arn) {
68
- this._roleArn = arn;
68
+ role(arnOrBuilder) {
69
+ if (typeof arnOrBuilder === "string") {
70
+ this._roleArn = arnOrBuilder;
71
+ }
72
+ else {
73
+ this._roleBuilder = arnOrBuilder;
74
+ }
69
75
  return this;
70
76
  }
71
77
  env(vars) {
@@ -73,6 +79,9 @@ export class LambdaBuilder extends BaseBuilder {
73
79
  return this;
74
80
  }
75
81
  async ensureRole() {
82
+ if (this._roleBuilder) {
83
+ return await this._roleBuilder.out.arn.get();
84
+ }
76
85
  if (this._roleArn)
77
86
  return this._roleArn;
78
87
  const roleName = `puls-lambda-${this.name}-role`;
@@ -14,6 +14,7 @@ export declare class RDSBuilder extends BaseBuilder {
14
14
  resolvedPort: number | null;
15
15
  resolvedArn: string | null;
16
16
  constructor(name: string);
17
+ get dbInstanceIdentifier(): string;
17
18
  engine(e: {
18
19
  engine: string;
19
20
  version: string;
@@ -26,6 +26,9 @@ export class RDSBuilder extends BaseBuilder {
26
26
  super(name);
27
27
  this.discoveryPromise = this.discoverInstance(name);
28
28
  }
29
+ get dbInstanceIdentifier() {
30
+ return this.name;
31
+ }
29
32
  engine(e) {
30
33
  this._engine = e.engine;
31
34
  this._engineVersion = e.version;
@@ -74,7 +77,7 @@ export class RDSBuilder extends BaseBuilder {
74
77
  return instance;
75
78
  }
76
79
  catch (e) {
77
- if (e.name === "DBInstanceNotFound")
80
+ if (e.name === "DBInstanceNotFound" || e.name === "DBInstanceNotFoundFault")
78
81
  return null;
79
82
  if (e.name === "CredentialsProviderError")
80
83
  return null;
@@ -0,0 +1,22 @@
1
+ import { BaseBuilder } from "../../core/resource.js";
2
+ import { Output } from "../../core/output.js";
3
+ export declare class SNSTopicBuilder extends BaseBuilder {
4
+ readonly out: {
5
+ arn: Output<string>;
6
+ };
7
+ private _displayName?;
8
+ private _subscriptions;
9
+ resolvedArn: string | null;
10
+ resolvedDisplayName: string | null;
11
+ constructor(name: string);
12
+ displayName(name: string): this;
13
+ subscribe(protocol: "email" | "sms" | "lambda" | "sqs" | "https", endpoint: string): this;
14
+ private discoverTopic;
15
+ deploy(): Promise<{
16
+ name: string;
17
+ arn: string | null;
18
+ }>;
19
+ destroy(): Promise<{
20
+ destroyed: string;
21
+ }>;
22
+ }
@@ -0,0 +1,146 @@
1
+ import { CreateTopicCommand, DeleteTopicCommand, GetTopicAttributesCommand, ListTopicsCommand, SetTopicAttributesCommand, SubscribeCommand, UnsubscribeCommand, ListSubscriptionsByTopicCommand, } from "@aws-sdk/client-sns";
2
+ import { BaseBuilder } from "../../core/resource.js";
3
+ import { Output } from "../../core/output.js";
4
+ import { getSNSClient } from "./api.js";
5
+ export class SNSTopicBuilder extends BaseBuilder {
6
+ out = {
7
+ arn: new Output(),
8
+ };
9
+ _displayName;
10
+ _subscriptions = [];
11
+ resolvedArn = null;
12
+ resolvedDisplayName = null;
13
+ constructor(name) {
14
+ super(name);
15
+ this.discoveryPromise = this.discoverTopic(name);
16
+ }
17
+ displayName(name) {
18
+ this._displayName = name;
19
+ return this;
20
+ }
21
+ subscribe(protocol, endpoint) {
22
+ this._subscriptions.push({ protocol, endpoint });
23
+ return this;
24
+ }
25
+ async discoverTopic(name) {
26
+ const sns = getSNSClient();
27
+ try {
28
+ let nextToken;
29
+ do {
30
+ const result = await sns.send(new ListTopicsCommand({ NextToken: nextToken }));
31
+ const match = (result.Topics ?? []).find((t) => t.TopicArn?.split(":").pop() === name);
32
+ if (match) {
33
+ this.resolvedArn = match.TopicArn ?? null;
34
+ if (this.resolvedArn) {
35
+ this.out.arn.resolve(this.resolvedArn);
36
+ try {
37
+ const attrsResult = await sns.send(new GetTopicAttributesCommand({ TopicArn: this.resolvedArn }));
38
+ this.resolvedDisplayName = attrsResult.Attributes?.DisplayName ?? null;
39
+ }
40
+ catch (err) {
41
+ // Ignore attribute fetch errors (e.g. permission or not found)
42
+ }
43
+ }
44
+ return match;
45
+ }
46
+ nextToken = result.NextToken;
47
+ } while (nextToken);
48
+ return null;
49
+ }
50
+ catch (e) {
51
+ if (e.name === "CredentialsProviderError")
52
+ return null;
53
+ throw e;
54
+ }
55
+ }
56
+ async deploy() {
57
+ const dryRun = this.isDryRunActive();
58
+ const existing = await this.discoveryPromise;
59
+ const sns = getSNSClient();
60
+ console.log(`\nšŸ“¢ Finalizing SNS Topic "${this.name}"...`);
61
+ if (dryRun) {
62
+ console.log(` šŸ“ [PLAN] ${existing ? "Update" : "Create"} SNS topic "${this.name}"`);
63
+ if (this._displayName) {
64
+ console.log(` └─ Display Name: ${this._displayName}`);
65
+ }
66
+ for (const sub of this._subscriptions) {
67
+ console.log(` └─ Subscribe: ${sub.protocol} to ${sub.endpoint}`);
68
+ }
69
+ this.resolvedArn = existing?.TopicArn ?? `arn:aws:sns:us-east-1:000000000000:DRYRUN-${this.name}`;
70
+ this.out.arn.resolve(this.resolvedArn);
71
+ return { name: this.name, arn: this.resolvedArn };
72
+ }
73
+ const topicAttrs = {};
74
+ if (this._displayName) {
75
+ topicAttrs.DisplayName = this._displayName;
76
+ }
77
+ if (!existing) {
78
+ const result = await sns.send(new CreateTopicCommand({
79
+ Name: this.name,
80
+ Attributes: topicAttrs,
81
+ }));
82
+ this.resolvedArn = result.TopicArn;
83
+ this.out.arn.resolve(this.resolvedArn);
84
+ console.log(`šŸš€ Created SNS Topic "${this.name}" (arn=${this.resolvedArn})`);
85
+ }
86
+ else {
87
+ this.resolvedArn = existing.TopicArn;
88
+ this.out.arn.resolve(this.resolvedArn);
89
+ if (this._displayName && this._displayName !== this.resolvedDisplayName) {
90
+ await sns.send(new SetTopicAttributesCommand({
91
+ TopicArn: this.resolvedArn,
92
+ AttributeName: "DisplayName",
93
+ AttributeValue: this._displayName,
94
+ }));
95
+ console.log(` āœ… Updated SNS topic display name to "${this._displayName}"`);
96
+ }
97
+ else {
98
+ console.log(` āœ… SNS topic "${this.name}" already exists`);
99
+ }
100
+ }
101
+ // Sync subscriptions
102
+ const activeSubsResult = await sns.send(new ListSubscriptionsByTopicCommand({ TopicArn: this.resolvedArn }));
103
+ const activeSubs = activeSubsResult.Subscriptions ?? [];
104
+ // 1. Unsubscribe stale subscriptions
105
+ for (const sub of activeSubs) {
106
+ if (!sub.SubscriptionArn || sub.SubscriptionArn === "PendingConfirmation")
107
+ continue;
108
+ const isStillWanted = this._subscriptions.some((s) => s.protocol === sub.Protocol && s.endpoint === sub.Endpoint);
109
+ if (!isStillWanted) {
110
+ await sns.send(new UnsubscribeCommand({ SubscriptionArn: sub.SubscriptionArn }));
111
+ console.log(` 🧹 Unsubscribed stale subscription: ${sub.Protocol} to ${sub.Endpoint}`);
112
+ }
113
+ }
114
+ // 2. Subscribe new subscriptions
115
+ for (const target of this._subscriptions) {
116
+ const alreadyExists = activeSubs.some((sub) => sub.Protocol === target.protocol && sub.Endpoint === target.endpoint);
117
+ if (!alreadyExists) {
118
+ await sns.send(new SubscribeCommand({
119
+ TopicArn: this.resolvedArn,
120
+ Protocol: target.protocol,
121
+ Endpoint: target.endpoint,
122
+ }));
123
+ console.log(` āž• Subscribed: ${target.protocol} to ${target.endpoint}`);
124
+ }
125
+ }
126
+ await this.deploySidecars();
127
+ return { name: this.name, arn: this.resolvedArn };
128
+ }
129
+ async destroy() {
130
+ const dryRun = this.isDryRunActive();
131
+ const existing = await this.discoveryPromise;
132
+ console.log(`\nšŸ—‘ļø Destroying SNS Topic "${this.name}"...`);
133
+ if (!existing) {
134
+ console.log(` āœ… Topic "${this.name}" does not exist - nothing to do`);
135
+ return { destroyed: this.name };
136
+ }
137
+ if (dryRun) {
138
+ console.log(` šŸ“ [PLAN] Delete SNS Topic "${this.name}"`);
139
+ return { destroyed: this.name };
140
+ }
141
+ const sns = getSNSClient();
142
+ await sns.send(new DeleteTopicCommand({ TopicArn: this.resolvedArn }));
143
+ console.log(` āœ… Deleted SNS Topic "${this.name}"`);
144
+ return { destroyed: this.name };
145
+ }
146
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,162 @@
1
+ import { test, describe, beforeEach, afterEach } from "node:test";
2
+ import assert from "node:assert";
3
+ import { SNSClient } from "@aws-sdk/client-sns";
4
+ import { SNSTopicBuilder } from "./sns.js";
5
+ import { Config } from "../../core/config.js";
6
+ describe("SNSTopicBuilder Unit Tests", () => {
7
+ let originalSnsSend;
8
+ let snsCalls = [];
9
+ let mockSnsResponses = {};
10
+ beforeEach(() => {
11
+ Config.set({
12
+ dryRun: false,
13
+ providers: {
14
+ aws: { region: "us-east-1" },
15
+ },
16
+ });
17
+ snsCalls = [];
18
+ mockSnsResponses = {};
19
+ originalSnsSend = SNSClient.prototype.send;
20
+ SNSClient.prototype.send = async function (command) {
21
+ const commandName = command.constructor.name;
22
+ const input = command.input;
23
+ snsCalls.push({ commandName, input });
24
+ if (mockSnsResponses[commandName]) {
25
+ const handler = mockSnsResponses[commandName];
26
+ if (typeof handler === "function")
27
+ return handler(input);
28
+ if (handler instanceof Error)
29
+ throw handler;
30
+ return handler;
31
+ }
32
+ return {};
33
+ };
34
+ });
35
+ afterEach(() => {
36
+ SNSClient.prototype.send = originalSnsSend;
37
+ });
38
+ test("gracefully handles discovery when topic does not exist", async () => {
39
+ mockSnsResponses["ListTopicsCommand"] = { Topics: [] };
40
+ const builder = new SNSTopicBuilder("my-topic");
41
+ const discoveryResult = await builder.discoveryPromise;
42
+ assert.strictEqual(discoveryResult, null);
43
+ assert.ok(snsCalls.some((c) => c.commandName === "ListTopicsCommand"));
44
+ });
45
+ test("discovers existing topic by matching name", async () => {
46
+ mockSnsResponses["ListTopicsCommand"] = {
47
+ Topics: [{ TopicArn: "arn:aws:sns:us-east-1:123456789012:my-topic" }],
48
+ };
49
+ mockSnsResponses["GetTopicAttributesCommand"] = {
50
+ Attributes: { DisplayName: "My Friendly Topic" },
51
+ };
52
+ const builder = new SNSTopicBuilder("my-topic");
53
+ const discoveryResult = await builder.discoveryPromise;
54
+ assert.ok(discoveryResult);
55
+ assert.strictEqual(builder.resolvedArn, "arn:aws:sns:us-east-1:123456789012:my-topic");
56
+ assert.strictEqual(builder.resolvedDisplayName, "My Friendly Topic");
57
+ const resolvedArn = await builder.out.arn.get();
58
+ assert.strictEqual(resolvedArn, "arn:aws:sns:us-east-1:123456789012:my-topic");
59
+ });
60
+ test("creates a new topic with display name and subscriptions", async () => {
61
+ mockSnsResponses["ListTopicsCommand"] = { Topics: [] };
62
+ mockSnsResponses["CreateTopicCommand"] = {
63
+ TopicArn: "arn:aws:sns:us-east-1:123456789012:my-topic",
64
+ };
65
+ mockSnsResponses["ListSubscriptionsByTopicCommand"] = { Subscriptions: [] };
66
+ const builder = new SNSTopicBuilder("my-topic")
67
+ .displayName("Cool Alert")
68
+ .subscribe("email", "ops@company.com")
69
+ .subscribe("sms", "+15555555555");
70
+ const deployResult = await builder.deploy();
71
+ assert.strictEqual(deployResult.arn, "arn:aws:sns:us-east-1:123456789012:my-topic");
72
+ assert.strictEqual(builder.resolvedArn, "arn:aws:sns:us-east-1:123456789012:my-topic");
73
+ const createCall = snsCalls.find((c) => c.commandName === "CreateTopicCommand");
74
+ assert.ok(createCall);
75
+ assert.deepStrictEqual(createCall.input, {
76
+ Name: "my-topic",
77
+ Attributes: { DisplayName: "Cool Alert" },
78
+ });
79
+ const subscribeCalls = snsCalls.filter((c) => c.commandName === "SubscribeCommand");
80
+ assert.strictEqual(subscribeCalls.length, 2);
81
+ assert.deepStrictEqual(subscribeCalls[0].input, {
82
+ TopicArn: "arn:aws:sns:us-east-1:123456789012:my-topic",
83
+ Protocol: "email",
84
+ Endpoint: "ops@company.com",
85
+ });
86
+ assert.deepStrictEqual(subscribeCalls[1].input, {
87
+ TopicArn: "arn:aws:sns:us-east-1:123456789012:my-topic",
88
+ Protocol: "sms",
89
+ Endpoint: "+15555555555",
90
+ });
91
+ });
92
+ test("syncs subscriptions correctly - unsubscribes stale and skips active", async () => {
93
+ mockSnsResponses["ListTopicsCommand"] = {
94
+ Topics: [{ TopicArn: "arn:aws:sns:us-east-1:123456789012:my-topic" }],
95
+ };
96
+ mockSnsResponses["GetTopicAttributesCommand"] = {
97
+ Attributes: { DisplayName: "Cool Alert" },
98
+ };
99
+ mockSnsResponses["ListSubscriptionsByTopicCommand"] = {
100
+ Subscriptions: [
101
+ {
102
+ SubscriptionArn: "arn:aws:sns:us-east-1:123456789012:my-topic:sub1",
103
+ Protocol: "email",
104
+ Endpoint: "keep-me@company.com",
105
+ TopicArn: "arn:aws:sns:us-east-1:123456789012:my-topic",
106
+ },
107
+ {
108
+ SubscriptionArn: "arn:aws:sns:us-east-1:123456789012:my-topic:sub2",
109
+ Protocol: "email",
110
+ Endpoint: "delete-me@company.com",
111
+ TopicArn: "arn:aws:sns:us-east-1:123456789012:my-topic",
112
+ },
113
+ ],
114
+ };
115
+ const builder = new SNSTopicBuilder("my-topic")
116
+ .displayName("Cool Alert")
117
+ .subscribe("email", "keep-me@company.com")
118
+ .subscribe("sms", "+19999999999");
119
+ await builder.deploy();
120
+ // Verify unsubscribe was called for stale one
121
+ const unsubscribeCall = snsCalls.find((c) => c.commandName === "UnsubscribeCommand");
122
+ assert.ok(unsubscribeCall);
123
+ assert.strictEqual(unsubscribeCall.input.SubscriptionArn, "arn:aws:sns:us-east-1:123456789012:my-topic:sub2");
124
+ // Verify subscribe was called for the new sms one, but NOT for keep-me@company.com
125
+ const subscribeCalls = snsCalls.filter((c) => c.commandName === "SubscribeCommand");
126
+ assert.strictEqual(subscribeCalls.length, 1);
127
+ assert.deepStrictEqual(subscribeCalls[0].input, {
128
+ TopicArn: "arn:aws:sns:us-east-1:123456789012:my-topic",
129
+ Protocol: "sms",
130
+ Endpoint: "+19999999999",
131
+ });
132
+ });
133
+ test("destroys an existing topic successfully", async () => {
134
+ mockSnsResponses["ListTopicsCommand"] = {
135
+ Topics: [{ TopicArn: "arn:aws:sns:us-east-1:123456789012:my-topic" }],
136
+ };
137
+ const builder = new SNSTopicBuilder("my-topic");
138
+ await builder.discoveryPromise;
139
+ const destroyResult = await builder.destroy();
140
+ assert.deepStrictEqual(destroyResult, { destroyed: "my-topic" });
141
+ const deleteCall = snsCalls.find((c) => c.commandName === "DeleteTopicCommand");
142
+ assert.ok(deleteCall);
143
+ assert.strictEqual(deleteCall.input.TopicArn, "arn:aws:sns:us-east-1:123456789012:my-topic");
144
+ });
145
+ test("runs in dry run mode safely", async () => {
146
+ Config.set({
147
+ dryRun: true,
148
+ providers: {
149
+ aws: { region: "us-east-1" },
150
+ },
151
+ });
152
+ mockSnsResponses["ListTopicsCommand"] = { Topics: [] };
153
+ const builder = new SNSTopicBuilder("my-topic")
154
+ .displayName("Cool Alert")
155
+ .subscribe("email", "ops@company.com");
156
+ const deployResult = await builder.deploy();
157
+ assert.ok(deployResult.arn.includes("DRYRUN"));
158
+ // No create topic or subscribe commands should be called in real mode
159
+ assert.ok(!snsCalls.some((c) => c.commandName === "CreateTopicCommand"));
160
+ assert.ok(!snsCalls.some((c) => c.commandName === "SubscribeCommand"));
161
+ });
162
+ });
@@ -0,0 +1,15 @@
1
+ import { BaseBuilder } from "../../core/resource.js";
2
+ export declare class FirebaseAppCheckBuilder extends BaseBuilder {
3
+ private _configs;
4
+ constructor();
5
+ enforce(serviceName: string): this;
6
+ unenforced(serviceName: string): this;
7
+ off(serviceName: string): this;
8
+ mode(serviceName: string, mode: "ENFORCED" | "UNENFORCED" | "OFF"): this;
9
+ deploy(): Promise<{
10
+ project: string;
11
+ }>;
12
+ destroy(): Promise<{
13
+ destroyed: string;
14
+ }>;
15
+ }
@@ -0,0 +1,109 @@
1
+ import { BaseBuilder } from "../../core/resource.js";
2
+ import { cloudFetch, getProjectId } from "./api.js";
3
+ const APP_CHECK_BASE = "https://firebaseappcheck.googleapis.com";
4
+ const SERVICE_IDS = {
5
+ firestore: "firestore.googleapis.com",
6
+ storage: "firebasestorage.googleapis.com",
7
+ database: "firebasedatabase.googleapis.com",
8
+ auth: "identitytoolkit.googleapis.com",
9
+ };
10
+ function resolveServiceId(name) {
11
+ return SERVICE_IDS[name.toLowerCase()] ?? name;
12
+ }
13
+ export class FirebaseAppCheckBuilder extends BaseBuilder {
14
+ _configs = new Map();
15
+ constructor() {
16
+ super("appcheck");
17
+ this.discoveryPromise = Promise.resolve(null);
18
+ }
19
+ enforce(serviceName) {
20
+ this._configs.set(resolveServiceId(serviceName), "ENFORCED");
21
+ return this;
22
+ }
23
+ unenforced(serviceName) {
24
+ this._configs.set(resolveServiceId(serviceName), "UNENFORCED");
25
+ return this;
26
+ }
27
+ off(serviceName) {
28
+ this._configs.set(resolveServiceId(serviceName), "OFF");
29
+ return this;
30
+ }
31
+ mode(serviceName, mode) {
32
+ this._configs.set(resolveServiceId(serviceName), mode);
33
+ return this;
34
+ }
35
+ async deploy() {
36
+ const dryRun = this.isDryRunActive();
37
+ const project = getProjectId();
38
+ console.log(`\nšŸ›”ļø Finalizing Firebase App Check...`);
39
+ if (dryRun) {
40
+ console.log(` šŸ“ [PLAN] Configure App Check enforcement modes:`);
41
+ for (const [serviceId, mode] of this._configs.entries()) {
42
+ console.log(` └─ ${serviceId}: ${mode}`);
43
+ }
44
+ return { project };
45
+ }
46
+ // 1. Fetch current status of each configured service
47
+ const existingConfigs = {};
48
+ for (const serviceId of this._configs.keys()) {
49
+ try {
50
+ const data = await cloudFetch(APP_CHECK_BASE, `/v1/projects/${project}/services/${serviceId}`);
51
+ existingConfigs[serviceId] = data.enforcementMode ?? "OFF";
52
+ }
53
+ catch (err) {
54
+ // If not found or not registered yet, default to OFF
55
+ existingConfigs[serviceId] = "OFF";
56
+ }
57
+ }
58
+ // 2. Patch services whose modes have changed
59
+ for (const [serviceId, mode] of this._configs.entries()) {
60
+ const existingMode = existingConfigs[serviceId] ?? "OFF";
61
+ if (existingMode !== mode) {
62
+ console.log(` šŸ”„ Updating App Check service "${serviceId}": ${existingMode} → ${mode}`);
63
+ await cloudFetch(APP_CHECK_BASE, `/v1/projects/${project}/services/${serviceId}?updateMask=enforcementMode`, {
64
+ method: "PATCH",
65
+ body: JSON.stringify({
66
+ name: `projects/${project}/services/${serviceId}`,
67
+ enforcementMode: mode,
68
+ }),
69
+ });
70
+ console.log(` āœ… App Check service "${serviceId}" updated to ${mode}`);
71
+ }
72
+ else {
73
+ console.log(` āœ… App Check service "${serviceId}" already set to ${mode}`);
74
+ }
75
+ }
76
+ await this.deploySidecars();
77
+ return { project };
78
+ }
79
+ async destroy() {
80
+ const dryRun = this.isDryRunActive();
81
+ const project = getProjectId();
82
+ console.log(`\nšŸ—‘ļø Destroying Firebase App Check...`);
83
+ console.log(` ā„¹ļø Reverting all configured services to OFF enforcement mode`);
84
+ if (dryRun) {
85
+ for (const serviceId of this._configs.keys()) {
86
+ console.log(` šŸ“ [PLAN] Revert ${serviceId} App Check to OFF`);
87
+ }
88
+ return { destroyed: "appcheck" };
89
+ }
90
+ for (const serviceId of this._configs.keys()) {
91
+ try {
92
+ console.log(` šŸ”„ Reverting App Check service "${serviceId}" to OFF...`);
93
+ await cloudFetch(APP_CHECK_BASE, `/v1/projects/${project}/services/${serviceId}?updateMask=enforcementMode`, {
94
+ method: "PATCH",
95
+ body: JSON.stringify({
96
+ name: `projects/${project}/services/${serviceId}`,
97
+ enforcementMode: "OFF",
98
+ }),
99
+ });
100
+ console.log(` āœ… App Check service "${serviceId}" reverted to OFF`);
101
+ }
102
+ catch (err) {
103
+ // Log error and continue teardown silently
104
+ }
105
+ }
106
+ await this.destroySidecars();
107
+ return { destroyed: "appcheck" };
108
+ }
109
+ }
@@ -0,0 +1 @@
1
+ export {};