t44 0.4.0-rc.3

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 (125) hide show
  1. package/.dco-signatures +9 -0
  2. package/.github/workflows/dco.yml +12 -0
  3. package/.o/GordianOpenIntegrity-CurrentLifehash.svg +1026 -0
  4. package/.o/GordianOpenIntegrity-InceptionLifehash.svg +1026 -0
  5. package/.o/GordianOpenIntegrity.yaml +25 -0
  6. package/.o/assets/Hero-Terminal44-v0.jpeg +0 -0
  7. package/DCO.md +34 -0
  8. package/LICENSE.md +203 -0
  9. package/README.md +183 -0
  10. package/bin/activate +36 -0
  11. package/bin/activate.ts +30 -0
  12. package/bin/postinstall.sh +19 -0
  13. package/bin/shell +27 -0
  14. package/bin/t44 +27 -0
  15. package/caps/ConfigSchemaStruct.ts +55 -0
  16. package/caps/Home.ts +51 -0
  17. package/caps/HomeRegistry.ts +313 -0
  18. package/caps/HomeRegistryFile.ts +144 -0
  19. package/caps/JsonSchemas.ts +220 -0
  20. package/caps/OpenApiSchema.ts +67 -0
  21. package/caps/PackageDescriptor.ts +88 -0
  22. package/caps/ProjectCatalogs.ts +153 -0
  23. package/caps/ProjectDeployment.ts +363 -0
  24. package/caps/ProjectDevelopment.ts +257 -0
  25. package/caps/ProjectPublishing.ts +522 -0
  26. package/caps/ProjectRack.ts +155 -0
  27. package/caps/ProjectRepository.ts +322 -0
  28. package/caps/RootKey.ts +219 -0
  29. package/caps/SigningKey.ts +243 -0
  30. package/caps/WorkspaceCli.ts +442 -0
  31. package/caps/WorkspaceConfig.ts +268 -0
  32. package/caps/WorkspaceConfig.yaml +71 -0
  33. package/caps/WorkspaceConfigFile.ts +799 -0
  34. package/caps/WorkspaceConnection.ts +249 -0
  35. package/caps/WorkspaceEntityConfig.ts +78 -0
  36. package/caps/WorkspaceEntityConfig.v0.ts +77 -0
  37. package/caps/WorkspaceEntityFact.ts +218 -0
  38. package/caps/WorkspaceInfo.ts +595 -0
  39. package/caps/WorkspaceInit.ts +30 -0
  40. package/caps/WorkspaceKey.ts +338 -0
  41. package/caps/WorkspaceModel.ts +373 -0
  42. package/caps/WorkspaceProjects.ts +636 -0
  43. package/caps/WorkspacePrompt.ts +406 -0
  44. package/caps/WorkspaceShell.sh +39 -0
  45. package/caps/WorkspaceShell.ts +104 -0
  46. package/caps/WorkspaceShell.yaml +64 -0
  47. package/caps/WorkspaceShellCli.ts +109 -0
  48. package/caps/WorkspaceTest.ts +167 -0
  49. package/caps/providers/README.md +2 -0
  50. package/caps/providers/bunny.net/ProjectDeployment.ts +327 -0
  51. package/caps/providers/bunny.net/api-pull.test.ts +319 -0
  52. package/caps/providers/bunny.net/api-pull.ts +164 -0
  53. package/caps/providers/bunny.net/api-storage.test.ts +168 -0
  54. package/caps/providers/bunny.net/api-storage.ts +248 -0
  55. package/caps/providers/bunny.net/api.ts +95 -0
  56. package/caps/providers/dynadot.com/ProjectDeployment.ts +202 -0
  57. package/caps/providers/dynadot.com/api-domains.test.ts +224 -0
  58. package/caps/providers/dynadot.com/api-domains.ts +169 -0
  59. package/caps/providers/dynadot.com/api-restful-v1.test.ts +190 -0
  60. package/caps/providers/dynadot.com/api-restful-v1.ts +94 -0
  61. package/caps/providers/dynadot.com/api-restful-v2.test.ts +200 -0
  62. package/caps/providers/dynadot.com/api-restful-v2.ts +94 -0
  63. package/caps/providers/git-scm.com/ProjectPublishing.ts +654 -0
  64. package/caps/providers/github.com/ProjectPublishing.ts +118 -0
  65. package/caps/providers/github.com/api.ts +115 -0
  66. package/caps/providers/npmjs.com/ProjectPublishing.ts +536 -0
  67. package/caps/providers/semver.org/ProjectPublishing.ts +286 -0
  68. package/caps/providers/vercel.com/ProjectDeployment.ts +326 -0
  69. package/caps/providers/vercel.com/api.test.ts +67 -0
  70. package/caps/providers/vercel.com/api.ts +132 -0
  71. package/caps/providers/vercel.com/bun.lock +194 -0
  72. package/caps/providers/vercel.com/package.json +10 -0
  73. package/caps/providers/vercel.com/project.test.ts +108 -0
  74. package/caps/providers/vercel.com/project.ts +150 -0
  75. package/caps/providers/vercel.com/tsconfig.json +28 -0
  76. package/docs/Overview.drawio +248 -0
  77. package/docs/Overview.svg +4 -0
  78. package/lib/crypto.ts +53 -0
  79. package/lib/key.ts +365 -0
  80. package/lib/schema-console-renderer.ts +181 -0
  81. package/lib/schema-resolver.ts +349 -0
  82. package/lib/ucan.ts +137 -0
  83. package/package.json +101 -0
  84. package/structs/HomeRegistry.ts +55 -0
  85. package/structs/HomeRegistryConfig.ts +56 -0
  86. package/structs/ProjectCatalogsConfig.ts +53 -0
  87. package/structs/ProjectDeploymentConfig.ts +56 -0
  88. package/structs/ProjectDeploymentFact.ts +106 -0
  89. package/structs/ProjectPublishingFact.ts +68 -0
  90. package/structs/ProjectRack.ts +51 -0
  91. package/structs/ProjectRackConfig.ts +56 -0
  92. package/structs/RepositoryOriginDescriptor.ts +51 -0
  93. package/structs/RootKeyConfig.ts +64 -0
  94. package/structs/SigningKeyConfig.ts +64 -0
  95. package/structs/Workspace.ts +56 -0
  96. package/structs/WorkspaceCatalogs.ts +56 -0
  97. package/structs/WorkspaceCliConfig.ts +53 -0
  98. package/structs/WorkspaceConfig.ts +64 -0
  99. package/structs/WorkspaceConfigFile.ts +50 -0
  100. package/structs/WorkspaceConfigFileMeta.ts +70 -0
  101. package/structs/WorkspaceKey.ts +55 -0
  102. package/structs/WorkspaceKeyConfig.ts +56 -0
  103. package/structs/WorkspaceMappingsConfig.ts +56 -0
  104. package/structs/WorkspaceProject.ts +104 -0
  105. package/structs/WorkspaceProjectsConfig.ts +67 -0
  106. package/structs/WorkspacePublishingConfig.ts +65 -0
  107. package/structs/WorkspaceShellConfig.ts +83 -0
  108. package/structs/providers/README.md +2 -0
  109. package/structs/providers/bunny.net/PullZoneFact.ts +55 -0
  110. package/structs/providers/bunny.net/PullZoneListFact.ts +55 -0
  111. package/structs/providers/bunny.net/StorageZoneFact.ts +55 -0
  112. package/structs/providers/bunny.net/StorageZoneListFact.ts +55 -0
  113. package/structs/providers/bunny.net/WorkspaceConnectionConfig.ts +43 -0
  114. package/structs/providers/dynadot.com/DomainFact.ts +46 -0
  115. package/structs/providers/dynadot.com/WorkspaceConnectionConfig.ts +54 -0
  116. package/structs/providers/git-scm.com/ProjectPublishingFact.ts +46 -0
  117. package/structs/providers/github.com/ProjectPublishingFact.ts +46 -0
  118. package/structs/providers/github.com/WorkspaceConnectionConfig.ts +43 -0
  119. package/structs/providers/npmjs.com/ProjectPublishingFact.ts +46 -0
  120. package/structs/providers/vercel.com/ProjectDeploymentFact.ts +55 -0
  121. package/structs/providers/vercel.com/WorkspaceConnectionConfig.ts +49 -0
  122. package/tests/01-Lifecycle/main.test.ts +173 -0
  123. package/tsconfig.json +28 -0
  124. package/workspace-rt.ts +134 -0
  125. package/workspace.yaml +3 -0
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env bun test
2
+
3
+ export const testConfig = {
4
+ group: 'vendor',
5
+ runOnAll: false,
6
+ }
7
+
8
+ import * as bunTest from 'bun:test'
9
+ import { run } from 't44/workspace-rt'
10
+
11
+ const {
12
+ test: { describe, it, expect },
13
+ domains
14
+ } = await run(async ({ encapsulate, CapsulePropertyTypes, makeImportStack }: any) => {
15
+ const spine = await encapsulate({
16
+ '#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
17
+ '#@stream44.studio/encapsulate/structs/Capsule': {},
18
+ '#': {
19
+ test: {
20
+ type: CapsulePropertyTypes.Mapping,
21
+ value: 't44/caps/WorkspaceTest',
22
+ options: {
23
+ '#': {
24
+ bunTest,
25
+ env: {
26
+ DYNADOT_API_KEY: { factReference: 't44/structs/providers/dynadot.com/WorkspaceConnectionConfig:apiKey' },
27
+ DYNADOT_API_SECRET: { factReference: 't44/structs/providers/dynadot.com/WorkspaceConnectionConfig:apiSecret' },
28
+ DYNADOT_API_KEY_TRANSACTION_SECRET: { factReference: 't44/structs/providers/dynadot.com/WorkspaceConnectionConfig:apiKeyTransactionSecret' }
29
+ }
30
+ }
31
+ }
32
+ },
33
+ domains: {
34
+ type: CapsulePropertyTypes.Mapping,
35
+ value: './api-domains'
36
+ },
37
+ }
38
+ }
39
+ }, {
40
+ importMeta: import.meta,
41
+ importStack: makeImportStack(),
42
+ capsuleName: 't44/caps/providers/dynadot.com/api-domains.test'
43
+ })
44
+ return { spine }
45
+ }, async ({ spine, apis }: any) => {
46
+ return apis[spine.capsuleSourceLineRef]
47
+ }, {
48
+ importMeta: import.meta
49
+ })
50
+
51
+ describe('Dynadot Domain API', function () {
52
+
53
+ let domainName: string
54
+
55
+ it('should return list of domains', async function () {
56
+
57
+ const result = await domains.list()
58
+
59
+ expect(result).toBeObject()
60
+ expect(result.code).toBe(200)
61
+ expect(result.data).toBeObject()
62
+ expect(result.data.domain_info).toBeArray()
63
+ expect(result.data.domain_info.length).toBeGreaterThan(0)
64
+
65
+ domainName = result.data.domain_info[0].domain_name
66
+ })
67
+
68
+ it('should return domain info for a specific domain', async function () {
69
+
70
+ const result = await domains.getInfo({ name: domainName })
71
+
72
+ expect(result).toBeObject()
73
+ expect(result.code).toBe(200)
74
+ expect(result.data).toBeObject()
75
+ expect(result.data.domain_info).toBeArray()
76
+ expect(result.data.domain_info[0].domain_name).toBe(domainName)
77
+ })
78
+
79
+ it('should return DNS records for a specific domain', async function () {
80
+
81
+ const result = await domains.getDns({ name: domainName })
82
+
83
+ expect(result).toBeObject()
84
+ expect(result.code).toBe(200)
85
+ expect(result.data).toBeObject()
86
+ expect(result.data.domain_info).toBeArray()
87
+ expect(result.data.domain_info[0].glue_info).toBeObject()
88
+ })
89
+
90
+ it('should return nameservers for a specific domain', async function () {
91
+
92
+ const result = await domains.getNameservers({ name: domainName })
93
+
94
+ expect(result).toBeObject()
95
+ expect(result.code).toBe(200)
96
+ })
97
+
98
+ it('should set an ANAME main domain record via v2 API', async function () {
99
+
100
+ // v2 API supports ANAME record type (v1 did not)
101
+ const setResult = await domains.setDns({
102
+ name: domainName,
103
+ records: [],
104
+ mainDomains: [{ recordType: 'aname', value: 'example.com' }],
105
+ })
106
+ expect(setResult).toBeObject()
107
+ expect(setResult.code).toBe(200)
108
+
109
+ // Verify ANAME was set
110
+ const verifyDns = await domains.getDns({ name: domainName })
111
+ expect(verifyDns.code).toBe(200)
112
+ const nsSettings = verifyDns.data.domain_info[0].glue_info?.name_server_settings
113
+ const mainRecords = nsSettings?.main_domains || []
114
+ const anameRecord = mainRecords.find((r: any) => r.record_type === 'aname')
115
+ expect(anameRecord).toBeDefined()
116
+ expect(anameRecord.value).toBe('example.com')
117
+
118
+ // Clean up: restore with a simple A record
119
+ await domains.setDns({
120
+ name: domainName,
121
+ records: [],
122
+ mainDomains: [{ recordType: 'a', value: '127.0.0.1' }]
123
+ })
124
+ })
125
+
126
+ it('should preserve existing records when using addToCurrent', async function () {
127
+
128
+ // First set a baseline record
129
+ const baseResult = await domains.setDns({
130
+ name: domainName,
131
+ records: [{ subdomain: 't44-test-preserve-base', record_type: 'cname', value: 'base.example.com' }],
132
+ mainDomains: [{ recordType: 'a', value: '127.0.0.1' }]
133
+ })
134
+ expect(baseResult.code).toBe(200)
135
+
136
+ // Add another record using addToCurrent — should preserve the base record
137
+ const addResult = await domains.setDns({
138
+ name: domainName,
139
+ records: [{ subdomain: 't44-test-preserve-add', record_type: 'cname', value: 'add.example.com' }],
140
+ mainDomains: [],
141
+ addToCurrent: true
142
+ })
143
+ expect(addResult.code).toBe(200)
144
+
145
+ // Verify both records exist
146
+ const verifyDns = await domains.getDns({ name: domainName })
147
+ const nsSettings = verifyDns.data.domain_info[0].glue_info?.name_server_settings
148
+ const subDomains = nsSettings?.sub_domains || []
149
+ const baseRecord = subDomains.find((r: any) => r.sub_host === 't44-test-preserve-base')
150
+ const addRecord = subDomains.find((r: any) => r.sub_host === 't44-test-preserve-add')
151
+ expect(baseRecord).toBeDefined()
152
+ expect(addRecord).toBeDefined()
153
+
154
+ // Clean up
155
+ await domains.setDns({
156
+ name: domainName,
157
+ records: [],
158
+ mainDomains: [{ recordType: 'a', value: '127.0.0.1' }]
159
+ })
160
+ })
161
+
162
+ it('should set DNS records, verify, and restore original', async function () {
163
+
164
+ const originalDns = await domains.getDns({ name: domainName })
165
+ expect(originalDns).toBeObject()
166
+ expect(originalDns.code).toBe(200)
167
+
168
+ const nsSettings = originalDns.data.domain_info[0].glue_info?.name_server_settings
169
+ const allOriginalRecords = nsSettings?.sub_domains || []
170
+ // Filter out any leftover test records to avoid duplicate subhost error
171
+ const originalRecords = allOriginalRecords.filter((r: any) => r.sub_host !== 't44-test-api-domains')
172
+
173
+ const testCnameRecord = {
174
+ record_type: 'cname',
175
+ subdomain: 't44-test-api-domains',
176
+ value: 'example.com'
177
+ }
178
+
179
+ const updatedRecords = [...originalRecords.map((r: any) => ({
180
+ record_type: r.record_type.toLowerCase(),
181
+ subdomain: r.sub_host,
182
+ value: r.value
183
+ })), testCnameRecord]
184
+
185
+ const setResult = await domains.setDns({
186
+ name: domainName,
187
+ records: updatedRecords
188
+ })
189
+ expect(setResult).toBeObject()
190
+ expect(setResult.code).toBe(200)
191
+
192
+ const verifyDns = await domains.getDns({ name: domainName })
193
+ expect(verifyDns).toBeObject()
194
+ expect(verifyDns.code).toBe(200)
195
+ const verifyNs = verifyDns.data.domain_info[0].glue_info?.name_server_settings
196
+ const verifyRecords = verifyNs?.sub_domains || []
197
+ expect(verifyRecords.length).toBe(originalRecords.length + 1)
198
+
199
+ const foundNewRecord = verifyRecords.find((r: any) => r.sub_host === 't44-test-api-domains')
200
+ expect(foundNewRecord).toBeDefined()
201
+ expect(foundNewRecord.record_type).toBe('cname')
202
+ expect(foundNewRecord.value).toBe('example.com')
203
+
204
+ // Restore original DNS by overwriting with original records
205
+ // If original had records, restore them; otherwise restore with just the original set
206
+ const restoreRecords = originalRecords.length > 0
207
+ ? originalRecords.map((r: any) => ({
208
+ record_type: r.record_type.toLowerCase(),
209
+ subdomain: r.sub_host,
210
+ value: r.value
211
+ }))
212
+ : [] // empty sub_list will clear subdomain records when dns_main_list is provided
213
+
214
+ const restoreResult = await domains.setDns({
215
+ name: domainName,
216
+ records: restoreRecords,
217
+ // Provide a main domain record to satisfy the "at least one DNS record" requirement
218
+ mainDomains: [{ recordType: 'a', value: '127.0.0.1' }]
219
+ })
220
+ expect(restoreResult).toBeObject()
221
+ expect(restoreResult.code).toBe(200)
222
+ })
223
+
224
+ })
@@ -0,0 +1,169 @@
1
+
2
+
3
+ export async function capsule({
4
+ encapsulate,
5
+ CapsulePropertyTypes,
6
+ makeImportStack
7
+ }: {
8
+ encapsulate: any
9
+ CapsulePropertyTypes: any
10
+ makeImportStack: any
11
+ }) {
12
+
13
+ // Dynadot REST API v2
14
+ // Docs: https://www.dynadot.com/domain/api-document
15
+ // All operations use v2 (snake_case response: domain_info, glue_info, etc.)
16
+ // v2 supports ANAME record type
17
+ // DNS records: POST domains/{name}/records (body: { dns_main_list, sub_list, ttl, add_dns_to_current_setting })
18
+ // DNS read: embedded in domain info via glue_info.name_server_settings
19
+ return encapsulate({
20
+ '#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
21
+ '#@stream44.studio/encapsulate/structs/Capsule': {},
22
+ '#t44/structs/providers/dynadot.com/DomainFact': {
23
+ as: '$DomainFact'
24
+ },
25
+ '#': {
26
+
27
+ api: {
28
+ type: CapsulePropertyTypes.Mapping,
29
+ value: './api-restful-v2'
30
+ },
31
+
32
+ list: {
33
+ type: CapsulePropertyTypes.Function,
34
+ value: async function (this: any) {
35
+ const result = await this.api.call({
36
+ method: 'GET',
37
+ path: 'domains',
38
+ operation: 'list'
39
+ });
40
+
41
+ await this.$DomainFact.set('list', result);
42
+
43
+ return result;
44
+ }
45
+ },
46
+
47
+ getInfo: {
48
+ type: CapsulePropertyTypes.Function,
49
+ value: async function (this: any, options: { name: string }) {
50
+ const result = await this.api.call({
51
+ method: 'GET',
52
+ path: `domains/${options.name}`,
53
+ operation: 'info'
54
+ });
55
+
56
+ await this.$DomainFact.set(options.name, result);
57
+
58
+ return result;
59
+ }
60
+ },
61
+
62
+ getDns: {
63
+ type: CapsulePropertyTypes.Function,
64
+ value: async function (this: any, options: { name: string }) {
65
+ // DNS info is embedded in domain info via glueInfo.name_server_settings
66
+ const result = await this.api.call({
67
+ method: 'GET',
68
+ path: `domains/${options.name}`,
69
+ operation: 'getDns'
70
+ });
71
+
72
+ await this.$DomainFact.set(options.name, result);
73
+
74
+ return result;
75
+ }
76
+ },
77
+
78
+ getNameservers: {
79
+ type: CapsulePropertyTypes.Function,
80
+ value: async function (this: any, options: { name: string }) {
81
+ const result = await this.api.call({
82
+ method: 'GET',
83
+ path: `domains/${options.name}/nameservers`,
84
+ operation: 'getNameservers'
85
+ });
86
+
87
+ await this.$DomainFact.set(options.name, result);
88
+
89
+ return result;
90
+ }
91
+ },
92
+
93
+ setDns: {
94
+ type: CapsulePropertyTypes.Function,
95
+ value: async function (this: any, options: {
96
+ name: string;
97
+ records: any[];
98
+ mainDomain?: { recordType: string; value: string; value2?: string };
99
+ mainDomains?: Array<{ recordType: string; value: string; value2?: string }>;
100
+ addToCurrent?: boolean;
101
+ }) {
102
+ const mainDomains = options.mainDomains || (options.mainDomain ? [options.mainDomain] : []);
103
+
104
+ // v1 REST API body format per docs:
105
+ // { dns_main_list, sub_list, ttl, add_dns_to_current_setting }
106
+ const body: Record<string, any> = {}
107
+
108
+ if (options.addToCurrent) {
109
+ body.add_dns_to_current_setting = true
110
+ }
111
+
112
+ // dns_main_list is required by the v2 API (at least one entry)
113
+ if (mainDomains.length > 0) {
114
+ body.dns_main_list = mainDomains.map(r => {
115
+ const record: any = {
116
+ record_type: r.recordType,
117
+ record_value1: r.value,
118
+ }
119
+ if (r.value2 !== undefined) {
120
+ record.record_value2 = r.value2
121
+ }
122
+ return record
123
+ })
124
+ } else {
125
+ // Fetch current main records to preserve them
126
+ const current = await this.api.call({
127
+ method: 'GET',
128
+ path: `domains/${options.name}`,
129
+ operation: 'getDns'
130
+ })
131
+ const ns = current?.data?.domain_info?.[0]?.glue_info?.name_server_settings || {}
132
+ const existingMain = ns.main_domains || []
133
+ body.dns_main_list = existingMain.length > 0
134
+ ? existingMain.map((r: any) => ({
135
+ record_type: r.record_type,
136
+ record_value1: r.value || r.record_value1,
137
+ ...(r.record_value2 ? { record_value2: r.record_value2 } : {}),
138
+ }))
139
+ : [{ record_type: 'a', record_value1: '127.0.0.1' }]
140
+ }
141
+
142
+ if (options.records.length > 0) {
143
+ body.sub_list = options.records.map(r => ({
144
+ sub_host: r.subdomain,
145
+ record_type: r.record_type,
146
+ record_value1: r.value,
147
+ }))
148
+ }
149
+
150
+ // POST /restful/v2/domains/{domain_name}/records
151
+ return await this.api.call({
152
+ method: 'POST',
153
+ path: `domains/${options.name}/records`,
154
+ body,
155
+ operation: 'setDns'
156
+ })
157
+ }
158
+ }
159
+
160
+ }
161
+ }
162
+ }, {
163
+ importMeta: import.meta,
164
+ importStack: makeImportStack(),
165
+ capsuleName: capsule['#'],
166
+ })
167
+ }
168
+ capsule['#'] = 't44/caps/providers/dynadot.com/api-domains'
169
+
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env bun test
2
+
3
+ export const testConfig = {
4
+ group: 'vendor',
5
+ runOnAll: false,
6
+ }
7
+
8
+ import * as bunTest from 'bun:test'
9
+ import { run } from 't44/workspace-rt'
10
+
11
+ const {
12
+ test: { describe, it, expect },
13
+ api
14
+ } = await run(async ({ encapsulate, CapsulePropertyTypes, makeImportStack }: any) => {
15
+ const spine = await encapsulate({
16
+ '#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
17
+ '#@stream44.studio/encapsulate/structs/Capsule': {},
18
+ '#': {
19
+ test: {
20
+ type: CapsulePropertyTypes.Mapping,
21
+ value: 't44/caps/WorkspaceTest',
22
+ options: {
23
+ '#': {
24
+ bunTest,
25
+ env: {
26
+ DYNADOT_API_KEY: { factReference: 't44/structs/providers/dynadot.com/WorkspaceConnectionConfig:apiKey' },
27
+ DYNADOT_API_SECRET: { factReference: 't44/structs/providers/dynadot.com/WorkspaceConnectionConfig:apiSecret' },
28
+ }
29
+ }
30
+ }
31
+ },
32
+ api: {
33
+ type: CapsulePropertyTypes.Mapping,
34
+ value: './api-restful-v1'
35
+ },
36
+ }
37
+ }
38
+ }, {
39
+ importMeta: import.meta,
40
+ importStack: makeImportStack(),
41
+ capsuleName: 't44/caps/providers/dynadot.com/api-restful-v1.test'
42
+ })
43
+ return { spine }
44
+ }, async ({ spine, apis }: any) => {
45
+ return apis[spine.capsuleSourceLineRef]
46
+ }, {
47
+ importMeta: import.meta
48
+ })
49
+
50
+ describe('Dynadot REST API v1', function () {
51
+
52
+ let domainName: string
53
+
54
+ it('should list domains', async function () {
55
+
56
+ const result = await api.call({
57
+ method: 'GET',
58
+ path: 'domains',
59
+ operation: 'list'
60
+ })
61
+
62
+ expect(result).toBeObject()
63
+ expect(result.code).toBe(200)
64
+ expect(result.data).toBeObject()
65
+ expect(result.data.domainInfo).toBeArray()
66
+ expect(result.data.domainInfo.length).toBeGreaterThan(0)
67
+
68
+ domainName = result.data.domainInfo[0].domainName
69
+ })
70
+
71
+ it('should get domain info for first domain', async function () {
72
+
73
+ const result = await api.call({
74
+ method: 'GET',
75
+ path: `domains/${domainName}`,
76
+ operation: 'info'
77
+ })
78
+
79
+ expect(result).toBeObject()
80
+ expect(result.code).toBe(200)
81
+ expect(result.data).toBeObject()
82
+ expect(result.data.domainInfo).toBeArray()
83
+ expect(result.data.domainInfo[0].domainName).toBe(domainName)
84
+ })
85
+
86
+ it('should set a TXT test record, verify, remove, and verify removal', async function () {
87
+
88
+ // 1. Get current DNS to snapshot existing records
89
+ const beforeDns = await api.call({
90
+ method: 'GET',
91
+ path: `domains/${domainName}`,
92
+ operation: 'getDns'
93
+ })
94
+ expect(beforeDns.code).toBe(200)
95
+ const nsBefore = beforeDns.data.domainInfo[0].glueInfo?.name_server_settings || {}
96
+ const existingMainRecords = nsBefore.main_records || nsBefore.dns_main_list || []
97
+ const existingSubs = nsBefore.sub_domains || []
98
+
99
+ // 2. Add a TXT test record using add_dns_to_current_setting to preserve all existing records
100
+ const setResult = await api.call({
101
+ method: 'POST',
102
+ path: `domains/${domainName}/records`,
103
+ body: {
104
+ add_dns_to_current_setting: true,
105
+ dns_main_list: [],
106
+ sub_list: [{
107
+ sub_host: 't44-v1-api-test',
108
+ record_type: 'txt',
109
+ record_value1: 'v1-test-value'
110
+ }]
111
+ },
112
+ operation: 'setDns'
113
+ })
114
+ expect(setResult).toBeObject()
115
+ expect(setResult.code).toBe(200)
116
+
117
+ // 3. Get DNS again — verify TXT record was added and existing records preserved
118
+ const afterSet = await api.call({
119
+ method: 'GET',
120
+ path: `domains/${domainName}`,
121
+ operation: 'getDns'
122
+ })
123
+ expect(afterSet.code).toBe(200)
124
+ const nsAfterSet = afterSet.data.domainInfo[0].glueInfo?.name_server_settings || {}
125
+ const subsAfterSet = nsAfterSet.sub_domains || []
126
+
127
+ const txtRecord = subsAfterSet.find((r: any) => r.sub_host === 't44-v1-api-test')
128
+ expect(txtRecord).toBeDefined()
129
+ expect(txtRecord.record_type).toBe('txt')
130
+ expect(txtRecord.value).toBe('v1-test-value')
131
+
132
+ // Verify existing subdomain records are still present
133
+ for (const orig of existingSubs) {
134
+ const found = subsAfterSet.find((r: any) => r.sub_host === orig.sub_host && r.record_type === orig.record_type)
135
+ expect(found).toBeDefined()
136
+ }
137
+
138
+ // 4. Remove the TXT record by re-setting all records WITHOUT the test record
139
+ const subsWithoutTest = subsAfterSet
140
+ .filter((r: any) => r.sub_host !== 't44-v1-api-test')
141
+ .map((r: any) => ({
142
+ sub_host: r.sub_host,
143
+ record_type: r.record_type,
144
+ record_value1: r.value,
145
+ }))
146
+
147
+ const mainRecordsForRestore = (nsAfterSet.main_records || nsAfterSet.dns_main_list || []).map((r: any) => ({
148
+ record_type: r.record_type,
149
+ record_value1: r.value || r.record_value1,
150
+ ...(r.record_value2 ? { record_value2: r.record_value2 } : {}),
151
+ }))
152
+
153
+ // Need at least one dns_main_list entry
154
+ const dnsMainList = mainRecordsForRestore.length > 0
155
+ ? mainRecordsForRestore
156
+ : [{ record_type: 'a', record_value1: '127.0.0.1' }]
157
+
158
+ const removeResult = await api.call({
159
+ method: 'POST',
160
+ path: `domains/${domainName}/records`,
161
+ body: {
162
+ dns_main_list: dnsMainList,
163
+ sub_list: subsWithoutTest,
164
+ },
165
+ operation: 'setDns'
166
+ })
167
+ expect(removeResult).toBeObject()
168
+ expect(removeResult.code).toBe(200)
169
+
170
+ // 5. Get DNS again — verify TXT record is gone and original records restored
171
+ const afterRemove = await api.call({
172
+ method: 'GET',
173
+ path: `domains/${domainName}`,
174
+ operation: 'getDns'
175
+ })
176
+ expect(afterRemove.code).toBe(200)
177
+ const nsAfterRemove = afterRemove.data.domainInfo[0].glueInfo?.name_server_settings || {}
178
+ const subsAfterRemove = nsAfterRemove.sub_domains || []
179
+
180
+ const removedRecord = subsAfterRemove.find((r: any) => r.sub_host === 't44-v1-api-test')
181
+ expect(removedRecord).toBeUndefined()
182
+
183
+ // Verify original subdomain records are still present
184
+ for (const orig of existingSubs) {
185
+ const found = subsAfterRemove.find((r: any) => r.sub_host === orig.sub_host && r.record_type === orig.record_type)
186
+ expect(found).toBeDefined()
187
+ }
188
+ })
189
+
190
+ })
@@ -0,0 +1,94 @@
1
+
2
+ import axios from 'axios';
3
+
4
+ export async function capsule({
5
+ encapsulate,
6
+ CapsulePropertyTypes,
7
+ makeImportStack
8
+ }: {
9
+ encapsulate: any
10
+ CapsulePropertyTypes: any
11
+ makeImportStack: any
12
+ }) {
13
+ // Dynadot REST API v1
14
+ // Docs: https://www.dynadot.com/domain/api-document
15
+ // Reference impl: https://github.com/level23/dynadot-api
16
+ // URL format: https://api.dynadot.com/restful/v1/{resource}/{resource_identify}/{action}
17
+ // Auth: Bearer apiKey, HMAC-SHA256 signed with apiSecret
18
+ return encapsulate({
19
+ '#@stream44.studio/encapsulate/spine-contracts/CapsuleSpineContract.v0': {
20
+ '#@stream44.studio/encapsulate/structs/Capsule': {},
21
+ '#t44/structs/providers/dynadot.com/WorkspaceConnectionConfig': {
22
+ as: '$ConnectionConfig'
23
+ },
24
+ '#': {
25
+
26
+ call: {
27
+ type: CapsulePropertyTypes.Function,
28
+ value: async function (this: any, options: {
29
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE';
30
+ path: string;
31
+ query?: Record<string, string>;
32
+ body?: any;
33
+ operation?: string;
34
+ }) {
35
+ const { createHmac, randomUUID } = await import('crypto')
36
+ const apiKey = await this.$ConnectionConfig.getConfigValue('apiKey')
37
+ const apiSecret = await this.$ConnectionConfig.getConfigValue('apiSecret')
38
+
39
+ const baseUrl = 'https://api.dynadot.com/restful/v1/'
40
+ const relativePath = options.path.replace(/^\//, '')
41
+ const requestId = randomUUID()
42
+
43
+ // Build request
44
+ const axiosOpts: any = {
45
+ method: options.method,
46
+ url: `${baseUrl}${relativePath}`,
47
+ headers: {
48
+ 'Authorization': `Bearer ${apiKey}`,
49
+ 'X-Request-Id': requestId,
50
+ 'Accept': 'application/json',
51
+ 'Content-Type': 'application/json',
52
+ },
53
+ validateStatus: () => true,
54
+ }
55
+
56
+ // For GET: params as query string, empty payload
57
+ // For POST/PUT: JSON body as payload
58
+ // Send pre-serialized string to ensure signature matches body exactly
59
+ let payloadJson = ''
60
+ if (options.method === 'GET' && options.query) {
61
+ axiosOpts.params = options.query
62
+ } else if (options.body) {
63
+ payloadJson = JSON.stringify(options.body)
64
+ axiosOpts.data = payloadJson
65
+ }
66
+
67
+ // HMAC-SHA256 signature: sign with apiSecret, relative path
68
+ const stringToSign = `${apiKey}\n${relativePath}\n${requestId}\n${payloadJson}`
69
+ const signature = createHmac('sha256', apiSecret).update(stringToSign).digest('hex')
70
+ axiosOpts.headers['X-Signature'] = signature
71
+
72
+ const response = await axios(axiosOpts)
73
+
74
+ if (response.status >= 400 || (response.data?.code && response.data.code >= 400)) {
75
+ const errorData = response.data
76
+ const operationName = options.operation || options.path
77
+ const desc = errorData?.error?.description || errorData?.message || JSON.stringify(errorData)
78
+ throw new Error(`Dynadot API ${operationName} failed: ${response.status} - ${desc}`)
79
+ }
80
+
81
+ return response.data
82
+ }
83
+ }
84
+
85
+ }
86
+ }
87
+ }, {
88
+ importMeta: import.meta,
89
+ importStack: makeImportStack(),
90
+ capsuleName: capsule['#'],
91
+ })
92
+ }
93
+ capsule['#'] = 't44/caps/providers/dynadot.com/api-restful-v1'
94
+